diff --git a/.changeset/pre.json b/.changeset/pre.json index 667043df0e..3b5a01fa8f 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -108,6 +108,7 @@ "adr-0078-completeness-gate", "adr-0078-phase3-webhook-triggers", "adr-0078-phase4-runtime-warns", + "adr-0078-status-calibration", "adr-0104-advertise-open-gates", "adr-0104-attestation-adr-note", "adr-0104-d1-media-strict-per-deployment", @@ -131,6 +132,7 @@ "adr-0104-remaining-flips-evidence", "adr-0104-remaining-flips-revision", "adr-0104-value-shapes-scan-gate", + "adr-0105-d6-unit-and-subordinates", "adr-0105-d8-delegated-admin-org-role", "adr-0105-d8-issuance-resolves-issuer-grants", "adr-0105-d9-cross-org-approver-targeting", @@ -160,6 +162,7 @@ "adr-0118-non-user-actor-contract", "adr-0119-d2-migration-journal", "adr-0119-plugin-reachable-transactions", + "adr-0121-endpoint-routing-namespace", "adr-anchors-authz-sweep", "adr-anchors-guard", "adr0104-attest-after-boot-writes", @@ -197,6 +200,7 @@ "analytics-or-not-combinators", "analytics-order-by-display-label", "analytics-query-bare-shape-entry-validation", + "analytics-raw-sql-object-routing", "analytics-read-scope-bridge-order", "analytics-record-scoping-and-measure-fields", "analytics-timedimension-projection", @@ -205,6 +209,8 @@ "api-methods-derivation-contract", "apimethod-enum-shrink", "apimethods-batch-conformance-ratchet", + "apis-loud-reject-and-apiregistry-retirement", + "apis-publish-gates-flip", "app-area-fail-open-gates-removed", "app-dead-authoring-keys", "app-metadata-reference-integrity-assessment", @@ -227,6 +233,8 @@ "approval-record-lock-predicate-updates", "approval-status-mirror-names-the-actor", "approval-vocabularies-derived", + "approvals-admin-exemption-retired", + "approvals-engineless-resume-gap", "approvals-expose-lock-record", "approvals-payload-labels", "approvals-stranded-request-inspection", @@ -237,9 +245,16 @@ "attachment-unscoped-multi-delete", "audit-anchor-and-lookup-integrity", "audit-provenance-and-import-vocabulary", + "audit-provisioning-datasource-audible", + "audit-skip-sys-job-queue", + "audit-skip-sys-upload-session", "audit-test-static-imports", + "auth-admin-audit-hook-bypass-claim-corrected", "auth-catchall-yields-unowned-paths", + "auth-database-hooks-middleware-claim-corrected", + "auth-invalid-membership-policy-outcome", "auth-lazy-cache-rate-limit-store", + "auth-membership-policy-setting", "auth-otp-budget-shared-counter-store", "auth-otp-cooldown-retention-follows-config", "auth-route-ledger", @@ -257,6 +272,7 @@ "batch-dropped-fields-observability", "batch-row-error-codes-registered", "batch-row-result-schema-shape", + "batch-upsert-existence-fork", "better-auth-1-7-0-rc-2-and-prod-dep-batch", "better-auth-team-member-count", "blueprint-formula-expression", @@ -265,15 +281,21 @@ "book-job-translation-app-authorwarn-keys-retired", "boot-api-merge", "boot-hydration-scoped-lookup", + "brace-expansion-advisory-5-0-9", "builtin-node-config-reconciliation", + "bulk-action-option-passthrough", "bulk-batch-size-cap", "bulk-data-event-contract", + "bulk-write-per-row-hook-semantics", + "bulk-write-previous-hook-condition-diagnostic", + "bulk-write-record-not-found", "bulk-writes-bind-to-path-object", "calendar-day-primitive-to-spec", "calendar-day-upper-bound-memory-mongodb", "calendar-day-upper-bound", "calldata-query-fallback-serves-query", "changelog-ships-in-tarball", + "chart-drilldown-declared", "chartconfig-trim-zoom-clickaction", "check-i18n-fails-on-undeclared-authoring-key", "check-mode-manifest-no-write", @@ -285,6 +307,8 @@ "ci-rerun-safety-and-timeouts", "ci-shard-test-core", "ci-test-completeness-guard", + "cli-banner-tenancy-posture", + "cli-email-provider-missing-api-key-throws", "cli-json-pipe-truncation-sweep", "cli-stale-dist-tests-and-project-root", "client-actions-surface", @@ -304,9 +328,14 @@ "close-the-eight-reports-rest-gaps", "close-the-final-nine-rest-gaps", "close-the-nine-metadata-rest-gaps", + "cluster-peer-write-invalidates-caches", + "code-only-metadata-types-refused-everywhere", "cold-boot-flow-bind-read-decorations", + "compose-stacks-no-silent-key-loss", + "config-resolution-email-example", "connector-authoring-guide", "connector-descriptors-meet-their-contract", + "connector-rate-limit-config-removed", "connector-template-cluster-removed", "console-09c6a177bb4a", "console-1bb77aa24514", @@ -332,12 +361,17 @@ "current-user-endpoints-kernel-resolver", "d12-fake-inventory-gate", "dashboard-strict", + "dashboard-widget-action-aria-removed", + "dashboard-widget-compareto-converged", + "dashboard-widget-responsive-removed", "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", + "database-loader-read-outage-is-not-a-miss", + "dataset-cross-datasource-compile-gate", "dataset-percent-scale-chain", "dataset-query-strip-read-decorations", "datasource-admin-503-names-its-own-service", @@ -359,15 +393,18 @@ "datetime-canonical-utc-storage", "datetime-storage-form-memory-mongodb", "ddl-runtime-token-default", + "dead-scan-roots-hard-error-three-checks", "decision-branch-routing-enforced", "decision-output-required-pin", "decision-outputs-surface-3447", "declarative-cron-job-schedule-envelope", + "declarative-endpoints-docs-skill-catchup", "declared-unique-index-not-legacy", "default-datasource-adopt-seam", "default-datasource-declared", "define-hook-factory", "degraded-boot-stderr-premise", + "degraded-list-cache-policy", "delegable-scope-read-surface", "delete-many-id-predicate", "department-approver-env-wide-business-unit", @@ -386,11 +423,17 @@ "dispatcher-handler-ready-gate", "dispatcher-returned-error-leak", "dispatcher-validation-error-fields", + "doc-authoring-covers-claude-corpus", + "doc-authoring-dead-root-hard-error", + "doc-authoring-roots-docs", + "doc-formula-example-cel-gate", "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-audit-releases-read-only", + "docs-audit-scope-derived", "docs-drift-nested-package-roots", "docs-drift-skip-test-files", "docs-extend-faq-reversed-rot", @@ -400,11 +443,13 @@ "document-weak-pass-variants", "dogfood-gate-cancelled-not-failure", "dogfood-shared-boot", + "dogfood-typecheck-wired", "domain-error-passthrough", "driver-capabilities-inert-bits-removed", "driver-conformance-gate", "driver-conformance-zero-discovery", "driver-connect-bound-and-reconnect-correction", + "driver-double-registration-log", "driver-options-bypass-tenant-audit", "driver-sql-logicalop-retention-note", "driver-sql-or-branch-and-semantics", @@ -420,15 +465,28 @@ "duplicate-fix-guard", "duplicate-package-flow-canonicalization", "durability-degradation-log-level", + "durability-gate-partial-recovery", + "durability-gate-savemetaitem", "durable-suspended-screen-refetch", "eighty-donkeys-repeat", + "email-durable-queue-delivery", + "email-outbox-boot-sweep", "email-template-materializer-bridge", "empty-capability-answers-501", + "empty-env-disabled-package-seed", "empty-group-bucket-key-null", "empty-state-gate-object-surface", "empty-state-semantics-gate", + "endpoint-chain-wiring", + "endpoint-execution-targets", + "endpoint-mapping-keys", + "endpoint-matcher-lazy-index", + "endpoint-mount-fallback-seam", + "endpoint-policy-keys", + "endpoint-publish-gate-backstop", "enforce-user-level-export-axis", "engine-callsites-canonical-keys", + "engine-double-delete-contract", "engine-filter-fold-every-method", "engine-rejects-unknown-option-keys", "engine-rejects-wire-only-aliases", @@ -436,6 +494,7 @@ "envelope-violations-predicate", "environment-artifact-dual-source-c10", "event-schema-dual-source-c6", + "executor-contract-surface-e1", "export-axis-opt-in", "export-empty-result-header", "export-honors-search-term", @@ -445,6 +504,7 @@ "fault-edge-guard-containment", "fault-edge-label-lint", "field-conditional-required-fold", + "field-expression-check-activated", "field-file-collection-dogfood-proof", "field-mapping-tri-source-c12", "field-readonly-doc-preserveaudit", @@ -488,6 +548,9 @@ "form-layout-lint-wired", "form-section-pane", "formview-buttons-defaults-live", + "fs-watch-invalidates-list-cache", + "gentle-buttons-shave", + "gentle-pumas-repeat", "govern-remaining-nine-metadata-types", "govern-report-dashboard-liveness", "govern-sys-member-writes", @@ -496,6 +559,7 @@ "group-union-driver-scope", "guard-refusal-chokepoint", "has-is-not-a-null-guard-lint", + "health-monitor-timeout-guard-cleared", "historical-import-audit-docs", "homepageid-tombstone-premise-corrected", "honest-service-self-description", @@ -513,10 +577,12 @@ "hook-empty-target-not-wildcard", "hook-vs-flow-path-guidance", "host-app-resolver-shared", + "host-declared-package-resolution", "http-contract-unification", "http-method-dual-source-c14", "http-protocol-discovery-two-shapes", "http-request-dual-source-c11", + "http-server-config-retired", "http-server-exemption-revoked", "hungry-donkeys-repeat", "i18n-bundle-drift-sweep", @@ -538,29 +604,42 @@ "import-historical-fsm", "import-sanitize-row-errors", "import-undo-preserveaudit", + "inbound-rate-limit-seam", "index-drift-migrate-plan", "inert-rule-warn-dedupe", + "init-service-contract-async-accessors", "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-queue-completed-retention", "job-retry-timeout-3494", "job-runtime-create-closed", + "json-schema-rule-format-enforced", + "jwt-eddsa-host-fallback", + "kernel-boot-hook-failure-propagation", "kernel-metadata-loader-envelope-removed", + "kernel-ready-unified-failure", + "kernel-shutdown-isolate-and-honest-timeout", "kernel-startup-timeout-guard-cleared", "keyset-batch-walks", + "large-email-attachments-storage-refs", "lazy-deps-dist-probe-timeout", "lifecycle-event-registry-enforced", + "lifecycle-retention-floor", + "lint-expressions-security-alias-reads", "lint-fieldless-object-skip", "lint-flag-record-change-trap", "lint-reference-integrity-suite", + "lint-rule-compilability-alias-read", "lint-searchable-fields-type-validation", "lint-system-fields-derived", "lint-translation-reference-integrity", "lint-unique-double-declaration", "list-column-prefix-summary-object", + "list-single-flight", "liveness-evidence-path-resolution", "liveness-governs-every-registered-type", "liveness-ledger-ai-scope-honesty", @@ -568,7 +647,9 @@ "liveness-register-orphan-proofs", "liveness-ten-preview-claims", "liveness-verified-at-clock", + "liveness-widget-drill-and-container-coverage", "localized-field-validation-messages", + "mail-provider-options-honest", "manifest-bridge-arm-on-project-kernels", "many-data-atomic-real-or-refused", "mapping-agent-page-strict", @@ -581,6 +662,8 @@ "membership-grade-not-capability-channel", "memory-datasource-ephemeral-per-pool", "memory-driver-opt-in-persistence", + "merge-dimension-key-unambiguous", + "merge-driver-worktree-independent", "messaging-declares-its-event-object", "messaging-outbox-no-updated-at-on-update", "meta-canonical-type-segment", @@ -607,12 +690,19 @@ "nav-access-lint", "nav-item-input-type", "next-event-seq-read-failure-loud", + "node-and-shutdown-timeout-guards-cleared", + "notification-action-embed-config-retired", "notification-dual-source-c3", "notification-orphan-template-schemas", "notifications-redos-fix", "notify-source-shape-conversion", + "now-default-token-engine-resolved", + "nul-gate-covers-all-text", + "null-guard-surface-coverage", + "null-safe-org-unique-driver", "object-enable-trash-mru-migration-surface", "object-parse-path-strict", + "object-subblock-strictness-batch20", "objectchart-aggregate-result-columns", "objectchart-contract-back-to-spec-shape", "objectql-crossobj-capability", @@ -622,23 +712,34 @@ "objectql-engine-contract", "objectql-strategy-daterange", "objectui-pin-changeset-from-declarations", + "objectui-pin-freshness-gate", + "objectui-range-from-declarations", + "openapi-declared-endpoints", "orderby-direction-vocabulary", + "org-axis-dead-alias-branches", + "org-axis-red-lines-read-spec-keys", + "org-create-effective-posture-gate", "osv-batch-2026-07-dep-bumps", + "osv-batch-2026-08-fixable-bumps", + "osv-exemption-conventions", "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", + "parent-scoped-readonly-when-server-enforced", "per-package-typecheck-coverage", "permission-backfill-row-state-columns", "pin-control-flow-designer-forms", "platform-always-on-capabilities", "platform-objects-app-i18n-phantom-debt", + "plenty-poems-brush", "plugin-lifecycle-hooks-retired", "plugin-ordering-declared-contract", "plugin-ordering-provider-declarations", "plugin-page-i18n-drift-guard", + "pm-dev-discipline-2026-08-04", "pm-dispatch-2026-08-03-ops-lessons", "pm-dispatch-agent-tooling", "pm-dispatch-domain-lanes", @@ -646,6 +747,8 @@ "pm-dispatch-round1-lessons", "pm-dispatch-triage-lessons", "pm-dispatch-triage-tooling", + "pm-findings-discipline", + "posture-misreads-sweep", "preserveaudit-test-and-docs", "preview-omits-virtual-fields", "previous-null-on-create-leg", @@ -661,6 +764,8 @@ "prune-report-column-grouping-schemas", "prune-skill-permissions", "public-book-grant", + "publish-draft-drain-discriminate", + "publish-drafts-endpoint-gate", "published-files-whitelist", "published-pm-dispatch-skill", "purge-webhook-delivery-i18n-and-bundle-ownership-guards", @@ -680,11 +785,13 @@ "readonly-flow-write-json-warning", "readonly-flow-write-lint", "readonly-flow-writes-joins-the-suite", + "readonly-strip-actionable-warning", "ready-probe-driver-health", "reconcile-ledger-in-ci", "reconcile-packages-post-and-ui-view-dialect", "record-after-write-trigger", "record-change-hydrate-formula-fields", + "record-delete-share-cascade", "recorded-by-nullable-lookup", "recursive-schema-input-pins", "recursive-schema-input-types", @@ -743,13 +850,16 @@ "retire-three-orphan-operator-vocabularies", "retirement-prescriptions-name-protocol-17", "retry-policy-dual-source-c8", + "retry-vocab-converge-inline-blocks", "rls-enabled-enforced-security-audit", + "rls-predicate-authoring-gate", "rls-priority-removed", "route-audit-tranche-3-service-mounts", "route-envelope-four-more-modules", "route-envelope-guard-dispatcher-domains", "route-ledger-audit-guard", "rpc-alias-precedence-one-fold", + "rule-compilability-publish-gate", "run-summary-uncountable-effects", "runner-setters-first-wins", "runtime-action-execution-module", @@ -764,11 +874,14 @@ "runtime-overlay-not-artifact", "runtime-packages-extraction", "runtime-share-links-extraction", + "runtime-typecheck-wired", "sandbox-structured-error-passthrough", "save-flow-canonicalization-fallback-warning", "save-meta-item-flow-canonicalization", "savemeta-persists-normalized-operators", + "scaffold-remote-template-namespace", "scaffolds-drop-memory-driver", + "schema-drift-runtime-managed-index", "schema-name-suffix-strip", "schemaless-node-config-contracts", "schemaless-node-expression-ledger", @@ -787,7 +900,10 @@ "seed-loader-composite-external-id", "seed-loader-dropped-reference-counter", "seed-loader-engine-schema-fallback", + "seed-loader-loud-failure-log-level", "seed-loader-multi-value-lookup", + "seed-loader-stale-summary-counter", + "seed-loader-unresolved-record-drop-loud", "seed-replay-tenant-stamp", "seed-state-machine-lint", "seed-summary-banner", @@ -804,21 +920,30 @@ "service-lookup-any-guard", "service-storage-success-envelope", "session-dual-source-c4", + "session-of-record-is-sys-session", "settings-error-details-declared-slot", + "settings-select-options-enforced", + "share-link-record-existence", "share-link-routes-envelope", "share-links-dispatcher-dual-key", "shared-response-envelope-builders", "sharing-access-level-full-removed", + "sharing-rule-bulk-write-recompute", "sharing-rule-criteria-required", "sharing-rule-recipient-reconcile", "sharing-rule-unknown-sort-and-stale-help", + "sharing-rule-unlowerable-condition-gate", "sharing-rule-withdrawal-and-delete", "showcase-action-disabled-specimen", "showcase-approver-and-picker-specimens", "showcase-bulk-actions-example", + "showcase-declarative-endpoints-restored", "showcase-global-action-specimen", + "showcase-inert-wirings", "showcase-legacy-rowactions-specimen", "showcase-nav-affordance-specimen", + "single-delete-binds-previous", + "skill-formula-condition-abort-scope", "skill-hook-condition-aborts", "skills-definehook-examples", "slot-contract-ledger-beyond-the-enum", @@ -827,19 +952,24 @@ "slot-lookup-sweep-auth-session", "slot-lookup-type-argument-ratchet", "slot-lookups-return-their-contract", + "smtp-transport-plugin-email", "sort-dotted-path-rejected", + "sort-node-direction-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-boolean-identity", "sql-driver-dialect-connect-timeout", + "sql-driver-not-null-safe", "sqlite-datetime-date-bucket", "sqlite-wal-journal-mode", "stack-storage-not-an-authoring-key", "stall-forensics-and-kernel-test-hygiene", "stall-guard-rollout", + "stall-guard-self-test", "standard-endpoints-parity-correction", "standard-endpoints-precedence-pin", "startup-log-noise-cleanup", @@ -848,10 +978,13 @@ "step2-prc-single-source", "storage-adapter-swap-verdict", "storage-download-filename", + "storage-metadata-loud-failure", "stored-metadata-replays-the-chain", "stored-migration-covers-flows", + "strict-automation-control-flow-state-machine", "strict-object-registered-types", "strictness-ledger-gate", + "strictness-ledger-numbers-prose-split", "strictness-ledger-recursive-coverage", "strictness-ledger-remeasure", "strip-read-decorations-on-save", @@ -860,6 +993,8 @@ "sweep-close-out", "sys-comment-record-level-authorization", "sys-comment-retire-visibility-reply-count", + "sys-email-headers-attachments", + "sys-metadata-repo-history-counters-loud", "sys-migration-ledger-platform-infra", "sys-secret-store-platform-infra", "sys-view-definition-default-open", @@ -879,6 +1014,7 @@ "tender-hats-brush", "test-core-stall-guard", "tests-off-memory-driver", + "theme-inert-token-scales-removed", "tidy-eyes-shine", "tombstone-agent-tools", "tool-inert-keys-removed", @@ -895,18 +1031,31 @@ "typed-decision-outputs-3447", "ui-discovery-reads-the-protocol-service", "ui-vocabularies-derive-not-restate", + "unique-scope-lint-rules", + "unique-scope-organization-vocabulary", "unique-tenant-scoped-materialization", "unknown-authoring-key-lint", "unknown-key-lint-nested-descent", + "unknown-key-strictness-automation-batch11", + "unknown-key-strictness-automation-etl", + "unknown-key-strictness-automation-node-config", "unknown-key-strictness-data-step", "unknown-key-strictness-step2", "unknown-key-strictness-step3", "unknown-key-strictness-tier-a", + "unknown-key-strictness-ui-batch13", + "unknown-key-strictness-ui-batch14", + "unknown-key-strictness-ui-batch15", + "unknown-key-strictness-ui-batch16", + "unknown-key-strictness-ui-batch17", "unknown-node-config-key-warning", "unordered-paged-read-determinism", + "unregister-invalidate-after-delete", "update-record-dropped-field-warnings", + "updatemany-idless-row-guard", "url-field-accepts-relative-urls", "user-field-implicit-target", + "user-filters-allow-add-tab-promote-and-close", "user-less-run-data-ops-refused", "user-level-export-axis", "v17-dissolve-protocol-alias", @@ -925,12 +1074,17 @@ "validate-runs-build-authoring-lints", "validation-kind-retired", "validation-variants-strict", + "vama-write-path-bypass", "variant-doc-drift-gate", "variant-docs-exemption-audit", "verify-harness-durable-suspended-runs", "verify-multitenant-requests-isolated-posture", "view-ast-operator-parity", + "view-filter-rule-console-id-hotfix", + "view-label-resolve-served-shape", "view-strict-final", + "view-subblock-strictness-batch18", + "vscode-snippets-parse-gate", "wait-loose-config-graduation", "wait-timeout-keys-retired", "wasm-sqlite-returning-writes-persist", @@ -938,6 +1092,7 @@ "webhook-dual-source-api-side", "webhook-liveness-ledger-flip", "webhooks-drop-dead-delivery-i18n", + "widget-contract-performanceconfig", "wildcard-fallthrough-guard", "withdraw-adr-0107-drop-writes-proposal", "workflow-slot-retired" diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 6446489e6a..d670a1d48e 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,59 @@ # @objectstack/example-crm +## 4.0.92-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [18b8eaa] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [2ddba89] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + ## 4.0.92-rc.2 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 6ab24c3626..d91d200577 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.2", + "version": "4.0.92-rc.3", "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 15e8dfe7dd..badaa4f122 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,157 @@ # @objectstack/example-showcase +## 0.3.14-rc.3 + +### Patch Changes + +- 3905c00: feat(objectql)!: a predicate bulk write evaluates and fires after-hooks PER ROW (#5038) + + The 2026-08-04 maintainer ruling on #4800 / #4862, recorded as ADR-0058's + bulk-write addendum: **a bulk write is N record changes**, so every record-scoped + declaration on it is evaluated per row — `record` = that row's state, `previous` = + that row's pre-write state. Validation predicates have worked this way since + #3106; hook `condition`s and the record-change flow triggers riding the same + lifecycle hooks now join them. + + **What was broken.** A `multi: true` update reaches `driver.updateMany`, which + resolves an affected COUNT. The lifecycle hook fired **once**, `previous` was + never assigned (only the single-id branch fetched a prior row), and `record` + degraded to the write's bare payload. So the transition condition the docs, the + formula skill and ten showcase flows all teach — + `status == "done" && previous.status != "done"` — could not be evaluated on a + bulk write. Hook conditions rejected the write (#4775/#5037); record-change flow + triggers were **silent**, firing zero times or once for a record that did not + exist. A missing audit row is the one failure nobody goes looking for. + + **What changed.** The engine's bulk `update` / `delete` branches now read the + matched row set **once** — the same `driver.find` #3106 already issues, with + "this object has after-hooks" added to its demand test — and dispatch + `afterUpdate` / `afterDelete` once per matched row, each on a context with the + **single-record shape**: `input.id` = the row, `previous` = its pre-image, + `result` = its state. That is #2922's batch-INSERT ruling restated, and it is why + this fix has no code in the consumers: `hook-wrappers`' `record`/`previous` + bindings, the record-change trigger's context builder and plugin-audit's diff all + read those same fields and became correct at the producer. + + - **Per-row dispatch is uniform across after-hooks.** It is deliberately NOT + keyed on whether a condition mentions `previous` — the ruling rejected that as + a hidden rule that would make a hook's firing count depend on its condition + text. + - **`ctx.result` per row is the ROW**, composed as `row ⊕ payload` from the + pre-image already in hand, so the batch still costs one extra query, not one + per row. A bulk DELETE has no post-state: its per-row context sets no `result`, + and consumers fall back to `previous`. + - **`onError` needed no new meaning** — it governs a handler on a record-scoped + context, which is now what it always gets: `abort` fails the operation, `log` + swallows that row and the batch continues. + - **A ceiling, enforced as a refusal.** Past 10 000 matched rows a predicate + write against an object with after-hooks is rejected _before_ the driver call + (`ERR_BULK_PER_ROW_HOOK_LIMIT`), so nothing is written. It is never downgraded + to one dispatch for the batch — that would skip the hook for N-1 rows silently. + + **Breaking for hook authors, in the direction the contract declares.** An + after-hook on an object that takes predicate writes now runs once per matched row + instead of once per batch: a notification hook sends N messages, a + cache-invalidation hook runs N times. Objects with no after-hooks are untouched + and pay for no extra read. The write's own contract is unchanged — a predicate + write still resolves the affected count and still publishes ONE aggregate + `data.records.updated` (#4639). + + **`before*` hooks stay batch-scoped, and that is not a gap.** `beforeUpdate` / + `beforeDelete` fire once for the whole batch because they may still rewrite the + payload, and one `updateMany` carries one payload. #5037's `HookConditionError` + and its `limitation` discriminator therefore **survive, rescoped to that + dispatch** — with a message that no longer promises an expiry that has already + happened, names the phase as the reason, and points at the matching `after*` + event where the same condition evaluates per row as authored. It also now names a + record-change flow trigger as a real route: #5037 refused to, on measured + evidence that the trigger shared the same unbound `previous`; that fact changed. + + Docs (`data-modeling/formulas.mdx`) and `skills/objectstack-formula` §5 are + updated to teach one transition shape for both write forms, with the `before*` + exception called out. + +- 9f41ee6: test(e2e,showcase): showcase 的 `apis:` 回迁,并由真实 boot 探针证明它真的在服务(#5040 E8) + + #4936 把 showcase 的两条声明式端点注释掉,不是因为它们写错了,而是因为当时整条端点链零执行:没有任何路由为声明的 `path` 挂载,没有匹配器,每一个键 —— 包括 `authRequired` —— 解析通过而不生效。那时候留着它们就是在演示一个运行时不兑现的能力(Prime Directive #10)。 + + #5040 的 E1–E7 把执行器建起来、把整面硬拒收窄成逐端点 publish 门之后,那条理由不复存在。本单按**原意**恢复这两条 —— 同名、同 target、同 `authRequired`、同 `cacheTtl` —— 只做 ADR-0121 D1 要求的一处修改:路径迁进本应用的命名空间保留区。 + + ``` + - path: '/api/v1/showcase/tasks' + + path: '/api/v1/apps/showcase/tasks' + + - path: '/api/v1/showcase/inquiries/purge' + + path: '/api/v1/apps/showcase/inquiries/purge' + ``` + + 这处修改不是装饰:`manifest.namespace: 'showcase'` 从此是发布的前置条件(声明了 `apis:` 却没有显式 namespace 会被 publish 拒绝),而 `apps/{namespace}/` 这一段让路由归属变成结构性的 —— 没有任何内建域住在 `apps/` 下,两个包也不可能因为 namespace 不同而撞车。 + + **匿名面没有增加**:两条历史声明本来就都是 `authRequired: true`,回迁后仍然是。一个例子不该长出它从来没有过的公开面。 + + coverage 清单里 `apis` 从 `waived` 翻回 `demonstrated`,理由重写为「由真实 boot 测量」而不是「声明即证明」—— 后者正是 #4936 抓到的那类假覆盖。支撑它的是两份新的真实 boot e2e:showcase 那份走真实 artifact 摄入路径,证明匹配命中执行(find 的 data 与内建 `/data` 路由逐字节相同)、匿名 401、`cacheTtl` 只随成功答案上线、挂载点下未声明路径与挂载点外的裸 404 完全一致、`/meta/api` 与 `/openapi.json` 描述的正是挂载的东西;fixture 那份补上 ADR-0121 D6 的匿名分支 —— 省略 `authRequired` 拒绝匿名、显式 `false` 服务匿名、已装配预算耗尽后 429 且 `Retry-After` 真的在线上。 + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [18b8eaa] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [2ddba89] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [f98fa65] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [193cd5c] +- Updated dependencies [5aae790] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/driver-sql@17.0.0-rc.3 + - @objectstack/cloud-connection@17.0.0-rc.3 + - @objectstack/connector-mcp@17.0.0-rc.3 + - @objectstack/connector-openapi@17.0.0-rc.3 + - @objectstack/connector-rest@17.0.0-rc.3 + - @objectstack/connector-slack@17.0.0-rc.3 + - @objectstack/service-datasource@17.0.0-rc.3 + ## 0.3.14-rc.2 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 851e7e639f..375e8e7c6d 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.2", + "version": "0.3.14-rc.3", "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 f0ade14509..e05c36b8e2 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/example-todo +## 4.0.92-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [0161c7f] +- Updated dependencies [533a0a4] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [3133cda] +- Updated dependencies [c142ced] +- Updated dependencies [c794f78] +- Updated dependencies [2b63a00] +- Updated dependencies [18b8eaa] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [ecc61ab] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [729a43a] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [5d3ced9] +- Updated dependencies [7c2f7dd] +- Updated dependencies [95b4f0d] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [db2ea82] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [dca25e1] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [e92e2c3] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/metadata@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/client@17.0.0-rc.3 + - @objectstack/mcp@17.0.0-rc.3 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.3 + - @objectstack/knowledge-memory@17.0.0-rc.3 + - @objectstack/service-knowledge@17.0.0-rc.3 + ## 4.0.92-rc.2 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 7caa51c353..7eff4ded9f 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.2", + "version": "4.0.92-rc.3", "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 4bb21cbd69..8bdd94ddc5 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,62 @@ # @objectstack/example-embed-objectql +## 0.0.32-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [2b63a00] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [5d3ced9] +- Updated dependencies [7c2f7dd] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [108ba8d] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [db2ea82] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/driver-memory@17.0.0-rc.3 + ## 0.0.32-rc.2 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index b8a8dafb44..a09e0e5f8c 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.2", + "version": "0.0.32-rc.3", "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 c02940da9e..9239c7c74c 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,27 @@ # @objectstack/hono +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [18b8eaa] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [08f93bc] +- Updated dependencies [02dc076] +- Updated dependencies [55dbbba] +- Updated dependencies [1203bb2] +- Updated dependencies [caf144a] +- Updated dependencies [2ddba89] + - @objectstack/plugin-hono-server@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 02c6edf389..ee5c13b9cc 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.2", + "version": "17.0.0-rc.3", "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 43a52c824a..483c557aed 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/account +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index b51e0a531f..b2af6e8a0e 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.2", + "version": "17.0.0-rc.3", "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 f6be3312d7..2f4f73e978 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/setup +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index 0a9f6656f4..bce539fd62 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.2", + "version": "17.0.0-rc.3", "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 6bb8152f1b..0343a5232e 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/studio +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index db964ab55f..aea1f99533 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.2", + "version": "17.0.0-rc.3", "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 55b3d3b1a2..615a6ed833 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,592 @@ # @objectstack/cli +## 17.0.0-rc.3 + +### Major Changes + +- bcfebb0: fix(cli,plugin-email)!: `OS_EMAIL_PROVIDER=resend/postmark` without an API key now fails the boot instead of silently becoming the log transport (#5132) + + **BREAKING for one configuration: a delivery provider selected without the + credential it needs.** `os serve` used to answer that by rewriting `provider` to + `log`, printing a warning, and booting normally. The result was a server that + accepted every send, recorded each one in `sys_email` as sent, and delivered + nothing — the warning scrolled past in CI logs and the truth surfaced when a + user reported never receiving a verification code. #5087 closed exactly this gap + inside `@objectstack/plugin-email` (`makeTransport` throws rather than + substituting a transport); the CLI's own capability assembly kept doing it one + layer up, for `resend` / `postmark`. + + `resolveEmailCapabilityArg` now refuses every mail configuration it cannot + deliver through, the way its neighbouring `smtp` arm already did: + + - `resend` / `postmark` with no `OS_EMAIL_API_KEY` (or `config.email.apiKey`); + - a `provider` tag outside `log` / `smtp` / `resend` / `postmark` — including + the retired `sendgrid` / `ses`, which get their SMTP migration in the message. + + **Who is affected:** deployments (typically CI or preview environments) that set + `OS_EMAIL_PROVIDER=resend` or `=postmark` without a key and relied on the + fallback to boot. Nothing else changes — a complete configuration is passed + through untouched, and an unset `OS_EMAIL_PROVIDER` still defaults to `log`. + + **Migration — one line, either direction:** + + - the environment is _not_ meant to send mail → `OS_EMAIL_PROVIDER=log` + (that explicit value is the supported way to say so, and why refusing the + others is fair); + - the environment _is_ meant to send mail → set `OS_EMAIL_API_KEY` (or + `config.email.apiKey`). + + Both errors name the consequence and both fixes, per AGENTS.md's + degradation-log-level rule. + + `@objectstack/plugin-email` gains the vocabulary the CLI reads instead of + restating: `API_KEY_EMAIL_PROVIDERS`, `emailProviderRequiresApiKey()` and the + `ApiKeyEmailProvider` type, alongside `EMAIL_TRANSPORT_PROVIDERS` / + `isEmailTransportProvider` / `unsupportedProviderFix` from #5094. One vocabulary, + two consumers, pinned by a contract test — a second literal list in the CLI is + how the settings dropdown and the transports drifted apart in the first place. + +### Minor Changes + +- 9c4f174: feat(plugin-email): durable email delivery through `sys_job_queue`, opt-in (#5160) + + `IEmailService.send()` has always delivered **inline**: the SMTP session ran + inside the caller's `await`, and `EmailService`'s retry loop lived in the same + process — so a crash between the attempt and the retry dropped the message with + no trace beyond a `sys_email` row stuck at `queued`. The pieces for a durable + path all existed (`sys_job_queue`, the `DbQueueAdapter`, an `email.send.async` + subscriber) but nothing in the repo ever published to that topic. + + **New: `queueDelivery`.** With it on, `send()` persists the `sys_email` row, + publishes an `email.send.async` job **referencing that row**, and returns + `{ status: 'queued' }` immediately. A worker delivers the row and finalizes it + in place (`sent` + `message_id`, or `failed` + `error`); the queue retries with + exponential backoff (1s → 5min cap) and dead-letters the job when the attempts + run out, so a restart resumes delivery instead of losing it. The `'queued'` + status was already in `EmailDeliveryStatus` — no spec change. + + Three ways to turn it on, all default-off: + + - `new EmailServicePlugin({ queueDelivery: true })` + - `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) on `os serve` + - Settings → Mail → **Durable queue delivery**, hot-applied without a restart + + **One retry budget, not two.** `retries` keeps its meaning — total attempts are + `retries + 1` in both modes. Inline it drives the in-process loop; queued it + becomes the queue's `maxAttempts` and the per-row loop is pinned to one attempt + per delivery. Turning the toggle on changes _where_ a retry happens (durable, + backed off) and never _how many_ happen, so the two layers cannot multiply. + + **Fixed in the same change: the `email.send.async` subscriber inserted a new + `sys_email` row per delivery.** It called `send()` with the message, so a job + the queue retried five times left five rows — four permanently `failed`, none + carrying the real attempt count. It now delivers the referenced row via + `deliverPersistedRow`, so one message is one row and `attempt_count` + accumulates on it. Messages published in the old shape (a bare `SendEmailInput`) + are still accepted and delivered inline for a migration window. + + Boundaries worth knowing before you switch it on: + + - **"Send test email" always sends inline**, in every mode — the button has to + report the provider's own answer (`535 …`), and "queued" is exactly the + non-answer #5087 removed from it. + - **Messages with attachments or custom headers are delivered inline**, because + `sys_email` has no columns for them and a queued copy would arrive stripped. + Queueing them is tracked separately; this ships the loss-free behaviour. + - **A declaration that cannot be honoured fails the boot.** `queueDelivery: true` + from the constructor or `OS_EMAIL_QUEUE_ENABLED` with no durable queue + registered (or with `persist: false`) throws on `kernel:ready`, naming the + fix — the #5132 judgement, applied to durability. The **settings toggle** is + the opposite trade: it logs at `error` and keeps sending inline, because one + save must not stop the mail. + - The kernel's built-in in-memory `queue` fallback does **not** count as a + durable queue: it delivers synchronously with no retry or DLQ, so publishing + to it would report `queued` for a message nothing could ever recover. Mount + `@objectstack/service-queue` over an ObjectQL engine (the `queue` capability + does this on `os serve`) to get the `sys_job_queue`-backed adapter. + + Leaving `queueDelivery` unset keeps today's behaviour byte for byte. + +- 02dc076: feat(types,cli,verify)!: 只解析 host app 声明过的包 —— `NODE_PATH` 不再算数,ADR-0093 D5 那道墙从此与启动方式无关 (#4719) + + **问题:契约写下了,但从没被检查过。** `@objectstack/types/node` 的 + `createHostRequire` 返回一个 CJS `createRequire`,而 CJS 解析认 `NODE_PATH` + (`Module.globalPaths`)。pnpm 生成的 bin shim 第一件事就是 + `export NODE_PATH=/node_modules/.pnpm/node_modules`,于是任何被工作区里 + **任意一个包**传递依赖到的包都能"从 host app 解析成功" —— 跟这个 app 声明了什么毫无关系。 + + 实测(cloud `apps/objectos-ee`,当时未声明 `@objectstack/organizations`): + `pnpm start`(经 shim)boot 成功、插件表里有 `Organizations`、ADR-0093 D5 一声不吭; + `node node_modules/@objectstack/cli/bin/run.js serve`(不经 shim)则 + `✖ FATAL: tenancy posture 'isolated' was requested…` 并 exit 1。同一个 app、同一份 + `package.json`、同一个 posture,**只因为进程是怎么被拉起来的**,走出两种结果。 + 而 D5 的报错一直在教 operator "declare it in the app's package.json" —— 那正是 + CLI 从来没检查过的那件事。 + + **改法:声明即执行。** 解析前先读 `/package.json`;只有包名出现在 + `dependencies` / `devDependencies` / `optionalDependencies` / `peerDependencies` + 的 **键**里,才去 host 的 `node_modules` 里查它。仅仅"能被解析到"不再算数 —— + 那正是让契约失效的那个偶然。未声明的包退回到 importing package 自身的解析 + (ESM,不认 `NODE_PATH`),框架自有的包加载路径不受影响。 + + **两种失败从此分开报。** 今天它们都塌成同一条 `MODULE_NOT_FOUND`,补救办法却相反: + + - **未声明** —— 指向"在 app 的 `package.json` 里声明并安装",并说明为什么 + hoisting / `NODE_PATH` 不被接受; + - **声明了但解析不到** —— 明确说这是**安装**问题(`pnpm install`、生产 prune + 砍掉了它、dist 没构建),别再让人回去重看那份已经写对的 `package.json`。 + + 分类经新导出的 `hostImportFailureKind(err)` 暴露给调用方;两种错误都仍带 + `code: 'MODULE_NOT_FOUND'`,`isModuleNotFoundError` 的既有判定不变。 + + **BREAKING — 哪类部署会从假绿变红,以及怎么修。** + + 1. **靠 hoisting 苟着的部署。** 一个 app 请求了 walled tenancy posture + (`OS_TENANCY_POSTURE=group` / `isolated` 或 `OS_MULTI_ORG_ENABLED=1`)、 + 却没在自己的 `package.json` 里声明 `@objectstack/organizations`,过去经 pnpm + shim 启动能正常 boot —— 现在会命中 ADR-0093 D5 并 exit 1。 + **修法:在那个 app 的 `package.json` 里声明该依赖并安装。** + 这些部署本来就在未声明状态下运行,红的是一直存在的事实,不是新引入的故障: + 同一个 app 不经 shim 启动今天就已经是 exit 1。 + (同样适用于 `@objectstack/service-ai` / `@objectstack/service-ai-studio`,以及 + `bootStack({ multiTenant: true })`、dogfood 的 enterprise 门。) + + 2. **`createHostImporter` 的签名变了**,因为它现在需要 host 的**根目录**才能读到 + 那份 manifest,而一个 `NodeRequire` 无法被问出它锚在哪里: + + ```diff + - createHostImporter(createHostRequire(hostRoot)) + + createHostImporter(hostRoot) // 省略参数 = process.cwd(),同旧默认 + ``` + + `createHostRequire` 本身保持不变,仍然导出。 + + 新增导出(`@objectstack/types/node`):`HOST_DECLARATION_FIELDS`、 + `HostDeclarationField`、`HostDeclaration`、`readHostDeclaration`、 + `isDeclaredByHost`、`packageNameFromSpecifier`、`HostImportFailureKind`、 + `HOST_IMPORT_FAILURE_KIND`、`hostImportFailureKind`。 + +### Patch Changes + +- b11a7e3: fix(cli): the boot banner's `Tenancy:` row now reports the resolved posture, not the superseded boolean (#4801) + + `printServerReady` printed `Tenancy: multi-tenant | single-tenant` from a boolean + `multiTenant` that `serve` filled with `resolveMultiOrgEnabled()` — i.e. from + `OS_MULTI_ORG_ENABLED`. [ADR-0105 D1] replaced that knob with + `OS_TENANCY_POSTURE`, keeping the boolean only as the fallback + `resolveTenancyPosture()` consults when the posture is unset, and **the runtime + wiring in `serve` already keys off the posture**. So the banner and the server it + describes read two different sources for one fact, and they drifted exactly where + it hurts: booting with `OS_TENANCY_POSTURE=isolated` and `OS_MULTI_ORG_ENABLED` + unset printed + + ``` + Tenancy: single-tenant + Plugins: 40 loaded + …, Organizations, … + ``` + + — the banner claiming single-org one line above the plugin table that proves the + organization wall is up (observed on a real boot in cloud#1020, where the lie was + only caught by hand-comparing the plugin list). + + This is not cosmetic. It is the "declared ≠ enforced" class (ADR-0049) landing on + the **diagnostic** surface, which is the worst place for it: a banner that can be + wrong costs every later investigation an extra lap proving whether it is. + + **What changes for users.** The row now prints the posture verbatim — `Tenancy: +single`, `Tenancy: group`, `Tenancy: isolated` — sourced from the same + `resolveTenancyPosture()` call the runtime wiring uses. The old `multi-tenant` / + `single-tenant` vocabulary is gone. That vocabulary was itself part of the defect: + tenancy has been a three-valued spectrum since ADR-0105, and a boolean has no + spelling for `group` at all, so a `group` deployment could only ever be + misreported. + + **The internal `multiTenant` option is removed, not deprecated.** With the posture + authoritative, a retained boolean could only ever be a field the printer ignores — + and a field that exists but cannot be believed is precisely how this bug was + authored in the first place. `ServerReadyOptions.tenancyPosture` is typed as + `TenancyPosture`, so re-wiring the banner to the legacy boolean now fails to + compile (`resolveMultiOrgEnabled()` returns `boolean`) instead of producing a + plausible-looking wrong line. The interface is package-internal — `format.ts` is + not re-exported from `@objectstack/cli`'s entry point — so no consumer code needs + a change. + + Regression-pinned in `packages/cli/src/utils/format.tenancy.test.ts`, which asserts + the printed token **is** `resolveTenancyPosture()`'s answer across the cases that + made the old code wrong: posture set with the boolean unset, posture unset with the + boolean true, both set and contradicting (either direction), the legacy `multi` + spelling, and `group`. + +- 55dbbba: feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937) + + Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec` + declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any + of them, so an author wrote a budget, it parsed, and nothing happened (#4686). + `@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense, + that the dispatcher called it and short-circuited with 429 — it had **zero call sites** + outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to + tune did not exist (#4937). Neither half was broken; they were simply never connected, and + both were documented as if they were. + + They are connected now, along one narrow path. + + ## What you write + + ```ts + export default defineStack({ + manifest: { + /* … */ + }, + server: { + security: { + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 }, + }, + trustProxy: false, + }, + }); + ``` + + `server:` is a **new** top-level stack key. Nothing declared it before, so no existing + stack changes behaviour on upgrade — there is no configuration that was inert yesterday + and starts throttling today. + + It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and + nothing else, because those are the two keys with a consumer. It is NOT the nine-key + `HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and + mounting them here would have made seven dead keys writable in one move (their + enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled + budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0` + is refused at `defineStack` rather than at 3am. + + **No `server.port`.** The listening socket belongs to the deployment, not the artifact, and + `objectstack serve -p` already owns it. The precedence rule is recorded in the schema and + the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` > + built-in default.** + + ## What happens + + Every inbound request the server routes — REST, dispatcher, service routes, anything + mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`, + refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429** + with a `Retry-After` computed from the bucket itself and the standard error envelope + (`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered. + + The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for + anonymous traffic — so one abusive session cannot spend another user's budget, and + credential-stuffing traffic (which has no principal yet) is still metered per source. That + IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**; + otherwise it is the transport's own peer address. Undeclared, those headers are attacker + input: honouring them by default would hand anyone an unlimited supply of fresh buckets and + let them drain a chosen victim's. + + Counters live in the kernel `cache` service when one is registered, so a multi-node + deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at + consume time so a cache plugin that registers later is still picked up (#4772). With no + cache service at all it falls back to a per-process store and says so once, naming the + consequence: the effective limit becomes the declared budget multiplied by the number of + nodes, and nothing about the deployment looks wrong. + + ## Also in this change + + - **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation + passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered + middleware could not read the request, write a response, or decline to continue — a + declared seam with no execution behind it, unnoticed because nothing called it. It now + delivers method/path/query/headers plus the transport peer address + (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be + registered before the routes it guards; the kernel's two-phase boot makes that automatic + (`init()` before every `start()`). + - **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it + does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous + in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is + now labelled as the reference material it always was rather than as live defaults. + + ## Explicitly NOT wired + + `ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain + **known-unwired**. Declaring them still changes nothing. They are not retired here either: + the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one + key of a surface that may yet be implemented would only have to be undone. Tracked, not + silent. + +- 2ddba89: fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262) + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — therefore reads `false` from + `resolveMultiOrgEnabled()` while running a fully mounted organization wall. + #5233 corrected two sites in `plugin-auth`; a census found eight more, all + written before that function's doc comment was corrected. Third recurrence of + the shape (cloud#1020, #5233). + + Each site was judged separately for **which** posture answers its question — + what the operator REQUESTED, or what the `tenancy` service reports is actually + IN FORCE — rather than converted mechanically: + + - `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the + REQUESTED posture (it is constructed below the kernel, with no service + registry to ask). The `organization_id` column was always provisioned; what + diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's + hottest predicate unindexed while SecurityPlugin compiled that same wall. + - `plugin-dev` — whether to load the enterprise `@objectstack/organizations`. + REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall, + so asking whether the wall is up would be circular. A posture-only dev stack + previously never loaded the package at all and served traffic unwalled. Its + diagnostic now names the posture that was requested instead of asserting + `OS_MULTI_ORG_ENABLED=true` at an operator who never set it. + - `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture, + via the `tenancy` service. These ask "will the per-org replay run instead of + me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so + keying on the request would defer to a replay that can never happen. Walled + deployments previously inline-seeded exactly the NULL-organization rows the + code's own comment exists to avoid. + - `cloud-connection` marketplace local install (install-time seed + rehydrate + heal) — EFFECTIVE posture, same reasoning. The install path is a write path: + a walled deployment wrote every sample row with no `organization_id`, landing + the app's data outside the wall its own reads apply. + - `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no + kernel to ask, and a suppressed warning is the costlier error for a + diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a + process-level fact into a per-instance verdict on whichever write landed + first. The gate now resolves live, which is affordable because + `auditMissingTenant` consults it only after the `tenantId` early-out. + - `cli` `os verify` — REQUESTED posture. This one produced a green verification + run over an unverified property: a posture-only deployment silently skipped + every multi-tenant proof and exited 0. + + **No configuration change is needed anywhere.** Deployments setting only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + belt-and-braces configuration stays valid. Deployments that set only + `OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is + unchanged at every site; only the knob each one reads is corrected. + +- 41c3b48: feat(plugin-email): real SMTP delivery — `SmtpTransport`, settings hot-swap, and a `mail/test` that actually sends (#5087) + + The **Mail Delivery** settings page has always defaulted to SMTP and offered a + full host / port / TLS / username / password form. Nothing behind it delivered: + `applyMailSettings` treated `provider: 'smtp'` as a no-op ("transport + unchanged"), `mail/test` answered `ok: true, "Configuration looks valid … Wire +@objectstack/plugin-mail for actual delivery"` — a success toast for a message + nobody sent, naming a package that has never existed — and the code pointed + operators at `@objectstack/plugin-mail-smtp`, which is not in this repo or on + npm. A workspace that selected SMTP got a green form, a green test button, and + mail that only ever reached the log and the `sys_email` table. For deployments + in China this left **no** working channel at all: Resend and Postmark are + overseas HTTPS SaaS with unreliable reach and deliverability to QQ / 163 / + enterprise mailboxes, where SMTP is the normal path (Aliyun DirectMail, Tencent + SES, corporate mail servers). + + **`SmtpTransport` now ships in `@objectstack/plugin-email`** (ADR-0012: SMTP in + core, implemented with `nodemailer`). `nodemailer` is a real dependency but is + imported **lazily on the first send**, so deployments that never select SMTP — + and non-Node runtimes — never load `node:net` / `node:tls`. + + Three doors reach it, all sharing one options reader so they cannot drift: + + - **Settings → Mail** (`smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` / + `smtp_password`) hot-swaps the live transport on save, no restart. + - **`os serve`** via `OS_EMAIL_PROVIDER=smtp` plus the new `OS_EMAIL_SMTP_HOST` / + `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` (or `config.email.options`). + - **Constructor**: `new EmailServicePlugin({ provider: 'smtp', providerOptions: +{ host, port, secure, user, password } })`. + + TLS is one toggle with the wire behaviour derived from the port, as providers + document it: on `465` implicit TLS (SMTPS); on any other port a **required** + STARTTLS upgrade, so a server that refuses to upgrade fails the send instead of + leaking credentials over a cleartext socket; `secure: false` connects in the + clear and upgrades only when STARTTLS is offered. + + **Failure is loud everywhere, because a silent fallback is the bug this fixes.** + On the construction path (CLI / plugin options) a `smtp` provider with no host + **throws** and the boot fails — it no longer degrades into a LogTransport that + reports every send as successful. On the settings hot-swap path a save can never + kill a running server, so the previous transport is kept — but the failure is + logged at `error` naming the consequence and the fix, and **`mail/test` now + performs a real delivery** through the settings on screen and reports the SMTP + server's own words (`535 … authentication failed`) instead of a green toast. The + built-in fallback `mail/test` handler (used only when no email plugin is + mounted) answers `ok: false` and says plainly that nothing was sent. + + Nothing to migrate: `log`, `resend` and `postmark` behave exactly as before, and + a deployment that never selects `smtp` is unaffected. + +- Updated dependencies [c1e67e0] +- Updated dependencies [f522e95] +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [7e5ac28] +- Updated dependencies [19e1a8f] +- Updated dependencies [c5e7bd9] +- Updated dependencies [0162c81] +- Updated dependencies [055f0c9] +- Updated dependencies [7cf1531] +- Updated dependencies [586d6f7] +- Updated dependencies [e96ad55] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [75bb3af] +- Updated dependencies [0161c7f] +- Updated dependencies [bcfebb0] +- Updated dependencies [533a0a4] +- Updated dependencies [1f82d1e] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [3133cda] +- Updated dependencies [1f0e7cb] +- Updated dependencies [c142ced] +- Updated dependencies [c794f78] +- Updated dependencies [641363a] +- Updated dependencies [2b63a00] +- Updated dependencies [18b8eaa] +- Updated dependencies [9c4f174] +- Updated dependencies [d25f20b] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [ecc61ab] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [06ffad3] +- Updated dependencies [729a43a] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [5d3ced9] +- Updated dependencies [9fa6bab] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [7c2f7dd] +- Updated dependencies [461ccda] +- Updated dependencies [f238970] +- Updated dependencies [95b4f0d] +- Updated dependencies [471839d] +- Updated dependencies [8597a7d] +- Updated dependencies [ff39e63] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [2f1e2a5] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [b821b29] +- Updated dependencies [af96af6] +- Updated dependencies [de113a4] +- Updated dependencies [caf144a] +- Updated dependencies [5b8f95b] +- Updated dependencies [da538b1] +- Updated dependencies [2ddba89] +- Updated dependencies [79822b5] +- Updated dependencies [15e61fb] +- Updated dependencies [37a8f2b] +- Updated dependencies [7f955e5] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [da1a64c] +- Updated dependencies [94f7b6a] +- Updated dependencies [f98fa65] +- Updated dependencies [1d29e6d] +- Updated dependencies [c5a5996] +- Updated dependencies [5ea8e1e] +- Updated dependencies [b40f81c] +- Updated dependencies [82a06af] +- Updated dependencies [6e66cbe] +- Updated dependencies [c272e48] +- Updated dependencies [ddd6650] +- Updated dependencies [db2ea82] +- Updated dependencies [41c3b48] +- Updated dependencies [f61c8cf] +- Updated dependencies [193cd5c] +- Updated dependencies [5aae790] +- Updated dependencies [718b229] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [dca25e1] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [e92e2c3] +- Updated dependencies [38f53a0] +- Updated dependencies [c183a12] +- Updated dependencies [69a89ce] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/lint@17.0.0-rc.3 + - @objectstack/service-analytics@17.0.0-rc.3 + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/plugin-hono-server@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/metadata-protocol@17.0.0-rc.3 + - @objectstack/metadata@17.0.0-rc.3 + - @objectstack/plugin-approvals@17.0.0-rc.3 + - @objectstack/plugin-audit@17.0.0-rc.3 + - @objectstack/plugin-auth@17.0.0-rc.3 + - @objectstack/service-settings@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/plugin-email@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/driver-sql@17.0.0-rc.3 + - @objectstack/verify@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/service-queue@17.0.0-rc.3 + - @objectstack/service-automation@17.0.0-rc.3 + - @objectstack/rest@17.0.0-rc.3 + - @objectstack/cloud-connection@17.0.0-rc.3 + - @objectstack/plugin-sharing@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + - @objectstack/plugin-security@17.0.0-rc.3 + - @objectstack/service-storage@17.0.0-rc.3 + - @objectstack/account@17.0.0-rc.3 + - @objectstack/setup@17.0.0-rc.3 + - @objectstack/client@17.0.0-rc.3 + - @objectstack/mcp@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + - @objectstack/driver-memory@17.0.0-rc.3 + - @objectstack/driver-mongodb@17.0.0-rc.3 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.3 + - @objectstack/plugin-reports@17.0.0-rc.3 + - @objectstack/plugin-webhooks@17.0.0-rc.3 + - @objectstack/service-cache@17.0.0-rc.3 + - @objectstack/service-datasource@17.0.0-rc.3 + - @objectstack/service-job@17.0.0-rc.3 + - @objectstack/service-messaging@17.0.0-rc.3 + - @objectstack/service-package@17.0.0-rc.3 + - @objectstack/service-realtime@17.0.0-rc.3 + - @objectstack/service-sms@17.0.0-rc.3 + - @objectstack/trigger-api@17.0.0-rc.3 + - @objectstack/trigger-record-change@17.0.0-rc.3 + - @objectstack/trigger-schedule@17.0.0-rc.3 + - @objectstack/plugin-pinyin-search@17.0.0-rc.3 + - @objectstack/console@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 3217acfb2b..1396f905ef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 69b4442af5..bcc5cf1ca1 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/client-react +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/client@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 8392fae2dd..6378c42f0b 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.2", + "version": "17.0.0-rc.3", "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 c0c989a3df..e604b4cc01 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/client +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/client/package.json b/packages/client/package.json index f5e3328756..c0d36b244c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 4bf6f757cf..3d736a990e 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/cloud-connection +## 17.0.0-rc.3 + +### Patch Changes + +- 2ddba89: fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262) + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — therefore reads `false` from + `resolveMultiOrgEnabled()` while running a fully mounted organization wall. + #5233 corrected two sites in `plugin-auth`; a census found eight more, all + written before that function's doc comment was corrected. Third recurrence of + the shape (cloud#1020, #5233). + + Each site was judged separately for **which** posture answers its question — + what the operator REQUESTED, or what the `tenancy` service reports is actually + IN FORCE — rather than converted mechanically: + + - `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the + REQUESTED posture (it is constructed below the kernel, with no service + registry to ask). The `organization_id` column was always provisioned; what + diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's + hottest predicate unindexed while SecurityPlugin compiled that same wall. + - `plugin-dev` — whether to load the enterprise `@objectstack/organizations`. + REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall, + so asking whether the wall is up would be circular. A posture-only dev stack + previously never loaded the package at all and served traffic unwalled. Its + diagnostic now names the posture that was requested instead of asserting + `OS_MULTI_ORG_ENABLED=true` at an operator who never set it. + - `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture, + via the `tenancy` service. These ask "will the per-org replay run instead of + me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so + keying on the request would defer to a replay that can never happen. Walled + deployments previously inline-seeded exactly the NULL-organization rows the + code's own comment exists to avoid. + - `cloud-connection` marketplace local install (install-time seed + rehydrate + heal) — EFFECTIVE posture, same reasoning. The install path is a write path: + a walled deployment wrote every sample row with no `organization_id`, landing + the app's data outside the wall its own reads apply. + - `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no + kernel to ask, and a suppressed warning is the costlier error for a + diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a + process-level fact into a per-instance verdict on whichever write landed + first. The gate now resolves live, which is affordable because + `auditMissingTenant` consults it only after the `tenantId` early-out. + - `cli` `os verify` — REQUESTED posture. This one produced a green verification + run over an unverified property: a posture-only deployment silently skipped + every multi-tenant proof and exited 0. + + **No configuration change is needed anywhere.** Deployments setting only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + belt-and-braces configuration stays valid. Deployments that set only + `OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is + unchanged at every site; only the knob each one reads is corrected. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [18b8eaa] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [2ddba89] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 79352177c9..042c52066a 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.2", + "version": "17.0.0-rc.3", "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 00e7d31023..05c2e49843 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/connector-mcp +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index a698b4073d..8ad752b1e8 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.2", + "version": "17.0.0-rc.3", "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 d75d81363c..ea8e19da6b 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/connector-openapi +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index df8dcdd0b6..e865ed7972 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.2", + "version": "17.0.0-rc.3", "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 b4d56fd491..bba31c473c 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/connector-rest +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 14923d6e68..70332a758a 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.2", + "version": "17.0.0-rc.3", "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 a7606d52a7..7b65be36f1 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/connector-slack +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 8f32137007..c5282f741b 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.2", + "version": "17.0.0-rc.3", "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 acefab5af9..f9314b1c84 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/console +## 17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 8b0d1d1058..ab163aec84 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 bca48ab174..26d75cdb16 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,337 @@ # @objectstack/core +## 17.0.0-rc.3 + +### Major Changes + +- 29c6c9d: feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) + + The declarative API-endpoint surface was **zero-execution end to end**, and said nothing + about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack` + accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact. + The execution side never fired once. On a real boot (showcase, 47 plugins) both declared + paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic + 404, because **no route was ever mounted** for a declared path, so the request died at + Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the + metadata service and called `matchEndpoint` on it — a method **no implementation in the + repo has ever provided**. The branch returned "not handled" on every request ever served. + + So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never + mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`, + `inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and + **`authRequired`**, a security semantic that parsed green and gated nothing at all. That + is false compliance, the failure ADR-0049 exists to stop, not debt. + + ## BREAKING — a non-empty `apis:` is now rejected + + Metadata that parsed cleanly before is now **refused at publish/validate**, with the + prescription in the rejection itself: + + ``` + apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, + so a non-empty array is rejected instead of silently accepted (#4936). … + ``` + + **FROM → TO.** `apis: [ …endpoints… ]` → `apis: []` (or delete the key; both are still + accepted, and an empty array is not a special case). To actually serve the route today, + mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server` + route. That is now the only honest path, and the one `examples/app-showcase` uses + (`src/system/server/recalc-endpoint.ts`). + + The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke + point every path runs through — `defineStack`, the metadata plugin's artifact ingestion, + `os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that + forgot to check. + + **The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and + rejected: endpoint shapes are an industry-stable form, so a retirement would only mean + re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript + and stay in the spec; only _authoring them into a stack_ is refused, and only until the + executor lands. Keep them commented next to your stack — that is what the showcase does. + The executor (route mounting + endpoint matching + per-key wiring for + `authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by + **#5040**, which replaces this rejection with real execution. + + ## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939) + + The repo carried a **second**, unrelated declaration shape for "an API endpoint": + `ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that + `createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every + assembly site lived in `packages/core/examples/`, with no registration in + `packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no + such service. The whole family was therefore inert, including + `ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense** + that "the gateway layer automatically validates these permissions" while no gateway read + it. Two declaration shapes, both dead; this retirement converges them on one. + + Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`, + `ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`, + `ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`, + `ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`, + `ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys). + Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`. + Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was + defaulted to `true` and read by nothing, configuring a service that was never composed. + + **FROM → TO.** There is no replacement shape to migrate to, because nothing executed the + old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`, + you were building a value only your own code read — keep it as your own type. Declarative + endpoints have one vocabulary now, `ApiEndpointSchema`. + + `ConflictResolutionStrategy` **survives** the removal and moved to + `@objectstack/spec/api`'s `router.zod` — same name, same four values + (`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two + independent ratchets and is not part of the retired surface. + + ## Also in this change + + - **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted, + along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves + `LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API + removal — but it returned `{ handled: false }` for every call it ever received, so no + caller can observe a behaviour change beyond the missing symbol. Delete the call. + Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing + at runtime with dead code behind it. + - `examples/app-showcase` no longer declares endpoints, and its coverage manifest no + longer claims the capability is `demonstrated` — that entry read "executed by the runtime + dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver + claim Prime Directive #10 forbids. + - The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**, + the live executor card, instead of #4936, which closes with this change. + +### Minor Changes + +- 0f2fdcd: fix(core)!: a throwing `kernel:bootstrapped` / `kernel:listening` handler fails the boot on LiteKernel too (#5257) + + **A failed `listen()` no longer yields a false "✅ Bootstrap complete".** + + #5170 (PR #5258) unified `kernel:ready`: a handler that throws fails the boot on + `ObjectKernel` and `LiteKernel` alike. It deliberately ruled that one hook only, + leaving the other lifecycle hooks split — `ObjectKernel` propagates their + failures (its `context.trigger` is a bare awaited loop that never catches) while + `LiteKernel` routed them through the isolating dispatcher, logging + `Hook handler failed: ` and carrying on. This closes the two boot-path + hooks that were left: `kernel:bootstrapped` and `kernel:listening` now use the + propagating dispatcher (`triggerHookOrThrow`) on `LiteKernel`, in the same shape + #5258 established — the remaining handlers for that hook are skipped, the later + boot hooks never fire, the original error reaches the caller **unwrapped**, + `state` is left `'stopped'` rather than `'running'`, and the success line is + never logged. + + The concrete failure this removes: `HonoServerPlugin` opens its socket inside a + `kernel:listening` handler — `await this.server.listen(port)`, with no try/catch + of its own, deliberately. When that rejected on `LiteKernel` (EACCES on a + privileged port, a failure inside the port-fallback logic itself, a serverless / + edge host where `listen` is not available at all) the throw was swallowed, + `bootstrap()` resolved normally, and the process printed + `✅ Bootstrap complete` while **nothing was listening**. The same plugin code on + `ObjectKernel` failed the boot. The health check that came next was the first + thing to notice, and it had already been told startup succeeded. Plain "port is + in use" was never affected — `server.listen` falls back to a random port + internally — which is exactly why this stayed invisible. + + `kernel:bootstrapped` carries reconcile and audit work (objectql's + `announceOpenMigrationGates`, service-automation's node-type / trigger-binding + audits, the sharing plugin's boot backfills); a swallowed failure there is a + quieter version of the same lie — the audit silently does not run. + + **`kernel:shutdown` keeps fail-soft dispatch**, now as an explicit per-hook + judgement recorded in a comment at the dispatch site rather than an inherited + default. On the teardown path there is no "refuse to proceed" left to buy, and + the handlers queued behind a failing one — plus the reverse-order `destroy()` + pass after them — are what flush buffers, close connections and release locks. + Aborting that sequence would convert one bad handler into leaked resources and + unflushed writes. + + **Who is affected.** Hosts that boot through `LiteKernel` — vitest, serverless, + edge (Workers) — and register a `kernel:bootstrapped` or `kernel:listening` + handler that can throw. Such a host previously came up "successfully" with the + work of that handler silently skipped; it now refuses to start and surfaces the + original error. If a handler of yours performs best-effort work whose failure + genuinely must not stop the boot, it needs its own `try/catch` — which is what + the in-repo `kernel:bootstrapped` subscribers already do, per handler, with the + reason written down. Nothing in this repo relied on the swallow: the core (426), + client, runtime, http-conformance, connector-{rest,mcp,slack} and + service-automation (665) suites pass unchanged. + + Boot assertions still belong in `kernel:ready`: it is the earliest hook at which + the service registry is finished filling. + +- 8ffa8b9: fix(core)!: a throwing `kernel:ready` handler now fails the boot on **LiteKernel** too (#5170) + + **Behaviour change — read this if you run `LiteKernel` (vitest harnesses, + serverless functions, edge workers).** A `kernel:ready` handler that throws now + **rejects `bootstrap()`** on `LiteKernel`, exactly as it always has on + `ObjectKernel`. Before this change the throw was caught inside the kernel, + written out as one `Hook handler failed: kernel:ready` error log, and the boot + continued to "✅ Bootstrap complete". + + **Why it mattered.** The two kernels ran the same hook through two different + dispatchers: `ObjectKernel` used `context.trigger` (a bare awaited loop that + never catches), `LiteKernel` used `triggerHook` (per-handler try/catch, + "continue with other handlers even if one fails"). Same hook name, same plugin + code, opposite failure semantics — which is `declared ≠ enforced` in the + kernel's own lifecycle contract. + + `kernel:ready` is the only correct moment for a plugin to assert that a + precondition it _declared_ was actually delivered: the service registry is + still filling during `init()`, so a boot gate has nowhere earlier to run. Every + "declare it and we refuse to start if we cannot honour it" gate in this repo + therefore lives there — and on `LiteKernel` those gates were being downgraded to + a log line while the process came up and served traffic without the guarantee it + had announced. `EmailServicePlugin`'s `queueDelivery: true` gate (#5160) is the + worked example: on `ObjectKernel` the boot failed, on `LiteKernel` the server + came up and quietly fell back to inline delivery. Serverless is exactly where + "do not start misconfigured" matters most. + + **Who is affected.** Any `LiteKernel` host whose `kernel:ready` handler throws + on a healthy boot. That boot previously "succeeded"; it now fails loudly with + the original error, and the kernel is left `stopped` rather than `running`. The + failure was never silent — it was already an `ERROR` line in your logs — so + check for `Hook handler failed: kernel:ready` in existing logs to find hosts + that will now refuse to start. If the handler's work is genuinely optional, + catch inside the handler and log there; the kernel no longer decides that for + you. The full test surface in this repo that boots `LiteKernel` (core, client, + runtime, http-conformance, the connectors, service-automation) passes unchanged + — nothing was relying on the swallow. + + Scope: **`kernel:ready` only.** `kernel:bootstrapped`, `kernel:listening` and + `kernel:shutdown` keep `LiteKernel`'s isolating dispatch, pinned by a test. + +### Patch Changes + +- eb3e650: fix(core): 健康检查的超时守卫在 race 落定时被清除,周期性检查不再堆积孤儿定时器 (#4875) + + `PluginHealthMonitor.performHealthCheck()` 里那条 race 的守卫由 `timeout()` armed 之后就被 + 扔掉:插件的 `checkMethod` 赢下 race 之后,那根 `setTimeout` 既没 `clearTimeout` 也没 + `unref()`,带着 ref 一直挂满整个 `config.timeout`。这与 #4813 修掉的两处(内核 init/start + 守卫,PR #4874)是同一种漏法。 + + 差别在于**健康检查是周期性的**:内核那两处是启动时一次性的固定份额(4 个插件 = 8 根),这里 + 则是**每个插件每一轮各留一根**,`interval` 越密、`timeout` 越长,堆得越高 —— 一个 + `interval: 30s` / `timeout: 5s` 的插件在任意时刻都挂着若干根本该在毫秒级就回收的定时器。 + 今天这条还没发作,只是因为 `startMonitoring()` 目前没有被内核启动流程调用;一旦健康监控被接进 + 宿主,它就是 #4813 的放大版。 + + 修法与 #4874 同形:`timeout()` 换成私有 helper `raceCheckTimeout()`,`try { await +Promise.race(...) } finally { clearTimeout(guard) }`。 + + **为什么是 `clearTimeout` 而不是 `unref()`。** `unref()` 让定时器不再钉住事件循环的同时, + 也让它不再是一个守卫 —— 若检查永不 settle 且没有别的东西撑着事件循环,Node 会在定时器触发 + 之前退出,超时被静默吞掉。守卫必须在 race 未决期间保持 ref'd、在落定那一刻被回收,这正是 + `finally { clearTimeout(guard) }` 表达的语义。回归测试因此是三条:守卫赢不了时不留 ref'd + 定时器、连跑多轮不累积(fake timers 下计数,能识破 `unref()` 式的假修复)、以及检查真的挂住时 + 超时照常上报。 + + 超时时长(`config.timeout`)一个都没动 —— 问题从来不在时长,而在没人回收。 + +- 674ac99: fix(core): one throwing `kernel:shutdown` handler no longer skips every plugin `destroy()` and kills the process under a false "Shutdown timed out" (#5274) + + **On `ObjectKernel`, a single bad shutdown subscriber used to end the entire teardown + and `process.exit(1)` the host — reporting a timeout that never happened.** + + `performShutdown()` dispatched `kernel:shutdown` through `context.trigger` (a bare + awaited loop that never catches), so the first handler that threw propagated out to + `shutdown()`'s `Promise.race` catch. That catch was written for the timeout race alone + and treated every exception as one, producing three consequences at once: + + 1. the remaining `kernel:shutdown` handlers never ran; + 2. **every** plugin's `destroy()` was skipped — the reverse-order destroy pass sits + after the trigger in `performShutdown()`, so it was never reached; + 3. the process was killed by `process.exit(1)` under the log line + `Shutdown timed out — forcing exit`, while nothing had timed out — sending whoever + read it to the `shutdownTimeout` config for a handler bug. + + Two changes, matching the reasoning #5257 recorded at `LiteKernel`'s shutdown dispatch + site: + + - **`kernel:shutdown` now dispatches ISOLATING on `ObjectKernel` too.** A handler that + throws is logged as `Hook handler failed: kernel:shutdown` and the remaining handlers + still run, followed by the reverse-order `destroy()` pass and the `onShutdown()` + handlers — both of which already isolated per plugin and per handler. What is queued + behind a failing shutdown handler is the cleanup that flushes buffers, closes + connections and releases locks, so one bad handler must not amplify into leaks and + unflushed writes. The BOOT-path hooks are untouched: `kernel:ready`, + `kernel:bootstrapped` and `kernel:listening` still propagate and still fail the boot + (#5170, #5257). + - **The timeout catch now handles only a genuine timeout**, discriminated by identity on + the timer's own rejection — not by message, not by type, so nothing a plugin throws + can impersonate it. A genuine `shutdownTimeout` overrun is **unchanged**: it still + logs `Shutdown timed out — forcing exit` and still calls `process.exit(1)`, because + teardown really is hung and the process would otherwise hold what it failed to + release. Any other exception is logged at `error` and follows the normal path — + `state = 'stopped'`, return — with no `process.exit`, leaving an embedding host + (cloud auth-proxy, CLI, a test runner) its own chance to finish cleanly. + + `shutdown()` still never rejects, so no existing caller changes. Telling the two paths + apart is the point of the fix, and both are pinned by named tests. + +- c5adfe1: fix: 节点执行与热重载 shutdown 的超时守卫在 race 落定时被清除,不再留下孤儿定时器 (#4952) + + #4813(PR #4874,内核 init/start)与 #4875(PR #4950,周期性健康检查)修掉的是同一种漏法: + 守卫 armed 之后就被扔掉 —— 被守护的一方赢下 race 之后,那根 `setTimeout` 既没 `clearTimeout` + 也没 `unref()`,带着 ref 一直把事件循环钉满整个超时预算。本次清仓剩下的两处生产实例: + + - **`AutomationEngine.executeWithTimeout()`**(`service-automation`)—— 三处里量级最大的一处: + **每个声明了 `timeoutMs` 的流程节点各一根**,孤儿数随流程节点数 × 触发频率线性增长;一次性进程 + (`os` CLI 跑到 flow 的路径)干完活之后还会被最长的那根守卫按住到超时才退出。 + - **`HotReloadManager.reloadPlugin()`**(`core`)—— 插件 `destroy()` 的 shutdown 守卫,与 #4813 + 修掉的两处一字不差:一次毫秒级完成的热重载,照样把循环钉满 `shutdownTimeout`。 + + 两处修法与 #4874 / #4950 同形,不新造变体:私有 helper + + `try { return await Promise.race([...]) } finally { clearTimeout(guard) }`。`hot-reload.ts` 的 + helper 把入参放宽到 `T | PromiseLike`(Plugin 契约允许同步 `destroy()`);`engine.ts` 的不放宽 + (`NodeExecutor.execute` 声明返回 `Promise`)。 + + **为什么是 `clearTimeout` 而不是 `unref()`。** `unref()` 让定时器不再钉住事件循环的同时,也让它 + 不再是一个守卫 —— 若被守护的一方永不 settle 且没有别的东西撑着事件循环,Node 会在定时器触发之前 + 退出,超时被静默吞掉。守卫必须在 race 未决期间保持 ref'd、在落定那一刻被回收,这正是 + `finally { clearTimeout(guard) }` 表达的语义。两处的回归测试各自沿用 #4950 的双向写法: + 真实定时器下不留 ref'd 定时器、fake timers 下连跑多轮不累积(计数能看见 `unref()` 过的定时器, + 因此识破 `unref()` 式的假修复)、以及被守护方真的挂住时超时照常上报。 + + 超时时长(`timeoutMs` / `shutdownTimeout`)一个都没动 —— 问题从来不在时长,而在没人回收。 + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 3931f0cd06..afd8e1ee33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 c804348a35..51044aeb8f 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,32 @@ # create-objectstack +## 17.0.0-rc.3 + +### Patch Changes + +- 8d41998: fix(create-objectstack): scaffolding a remote template no longer produces a project that cannot build (#4926) + + `npx create-objectstack@latest my-app -t todo` (and `compliance`, `content`, + `contracts`, `procurement`) generated a project that failed `objectstack build` + immediately — 5 of the 6 offered templates. Only the bundled `blank` worked. + + The scaffolder read the template's original namespace from + `objectstack.manifest.json`, and that filename names two different documents. + The bundled template's is app-shaped and carries `namespace`; a remote + template's is the template-registry document + (`$schema: …/template-manifest.json`) and carries none — its namespace lives + only in `objectstack.config.ts`. So the value came back `undefined` for every + remote template and the object-name rewrite was skipped, while the config's + `namespace:` was rewritten anyway. The result was `namespace: 'my_app'` sitting + next to `name: 'todo_task'`, which the `${namespace}_${shortName}` rule rejects. + Across the five templates, 74 object names were left unrewritten. + + `objectstack.config.ts` is now the authority for the template namespace (it + holds the very literal the scaffolder overwrites, so the two cannot disagree), + with the manifest as fallback. The rewrite also verifies itself: any surviving + stale prefix throws at the scaffold, naming the files and lines, instead of + surfacing as a build failure on the user's first command. + ## 17.0.0-rc.2 ## 17.0.0-rc.1 diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 95f7311b16..96b963f1ff 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.2", + "version": "17.0.0-rc.3", "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 4d80fe54b2..5355baf17d 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/formula +## 17.0.0-rc.3 + +### Minor Changes + +- bf1edef: feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983) + + `isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4: + "exposed so an authoring-time gate (`objectstack compile`) can REJECT a + predicate the runtime would silently drop … A `false` here means 'this + predicate will never enforce'." It had **no non-test consumer anywhere** — the + function written to fix declared-but-never-read was itself declared and never + read. This lands the consumer, in two steps that had to happen in this order. + + **1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM + `@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula` + (`src/rls-predicate.ts`), and are exported from its root.** Executable code + unchanged — a change of address, not of behaviour; `plugin-security` now imports + them from `@objectstack/formula` and keeps no copy, so there is still exactly + one definition. No import path outside the two packages changes: neither symbol + was ever exported from `@objectstack/plugin-security`'s entry point. The move is + what makes step 2 possible at all — `@objectstack/lint` may depend on + `@objectstack/spec` and never on a runtime, so with the predicate living in a + runtime the gate's only other door was copying the SQL→CEL bridge, whose + boundary conditions (quoted literals are never rewritten; canonical CEL passes + through unchanged) _are_ the gate's red/green line. A fork drifting by one + character rejects policies the runtime executes correctly — the false-positive + direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical + shape gate; the bridge is part of that gate. + + **2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`, + `error`, on all three authoring commands**, over + `permissions[].rowLevelSecurity[].using` and `.check`: + + - **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown + subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a + cross-object path (`record.account.region`). + - **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy + SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery. + Its own id because the fix is different — write CEL (`&&`, `||`), not a + different shape. + + What the gate prevents, measured through `plugin-security` rather than inferred: + `RLSCompiler` drops the policy and logs one request-time WARN. On the read path, + when it is the only applicable policy, `compileFilter` returns the + `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so + every select / update / delete on the object matches **zero rows**. On the + ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which + no record satisfies, so every insert / update fails with `PermissionDeniedError`. + The runtime fails closed, which is why this was survivable: the result is not a + hole but a policy that reads as an authorization and behaves as a blanket + refusal, with nothing at authoring time pointing at the line that caused it. + + Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=` + `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and + `startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058 + D2), against a literal or a `current_user.*` value. Two specific migrations: + `has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object + _validation_ rule, which is interpreted, and wrong here, where the predicate is + compiled to a filter); and a related record's field → denormalise it onto this + object (formula/rollup) and test that column, since RLS cannot join (ADR-0055). + + Same construction as the sharing-rule gate (#4698): the rule does not model the + consumer or grep for it — it calls `isSupportedRlsExpression`, the exact + function `RLSCompiler.compileFilter` consults to decide whether a dropped policy + earns its warning, so the two verdicts are one boolean by construction, pinned + in both directions over a shared corpus. Measured before shipping: every RLS + predicate declared anywhere in this repo — the `plugin-security` platform seeds, + the examples, the dogfood fixtures, the authoring skill — is supported, so the + gate turns nothing red that works today. Unlike the sharing-rule gate, CEL + _syntax_ is reported here rather than deferred to `expression-invalid`: + `validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not + judge this field correctly if it did, because `owner_id = current_user.id` is a + CEL syntax error and a working RLS predicate at the same time. + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 3a220d4a75..98f668214d 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 491b392b3a..2be7af29bc 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,962 @@ # @objectstack/lint +## 17.0.0-rc.3 + +### Minor Changes + +- 0161c7f: feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022) + + `drillDown` has driven a real capability since long before this release: click a + bar or a slice on an `` and objectui opens the underlying records, + filtered by the clicked category, in a drawer. The protocol declared it + **nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside + it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was + simply ignored at click time. This is Prime Directive #10 inverted: not declared + without being delivered, but delivered without ever being declared. + + It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing + that parsed before stops parsing. + + ## What you can write + + `drillDown` is a prop on the react-tier `` block: + + ```jsx + + ``` + + | key | type | meaning | + | --------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | + | `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it | + | `filter` | `Record` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category | + | `title` | `string` | Drawer/dialog heading; supports `${event.*}` | + | `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer | + | `columns` | `string[]` | Column whitelist for the drilled list | + | `maxRows` | `number` | Rows per page in the drilled list | + + Every one of those six is a key objectui's `ObjectChart` was measured to read. + The renderer's own drill type is wider — it is shared with the table / pivot / + metric widgets — and the extra keys are **deliberately not declared**, because a + chart reads none of them: + + - **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment + is always an aggregate, so there is nothing to discriminate. + - **`report`** (drill into a report instead of a record list) is a metric/pivot + capability. + - **`view`** and **`sort`** are read by _no_ renderer at all (objectui#3354). + - **`target: 'navigate'`** is implemented for the other widgets but not for a + chart, which falls back to the drawer. + + Writing any of them is now a loud rejection that says which surface owns it, + rather than a value that silently does nothing. + + ## Where it is NOT declared, and why that is deliberate + + **Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard + widget has no per-widget drill configuration, by design: an ADR-0021 + dataset-bound widget drills through the semantic layer, deriving the target + object and filter from the dataset row that was clicked. That is what + `content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer + does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget` + forwards exactly one key out of it (`showLegend`). Declaring the drill there + would have produced authorable metadata that parses clean and never reaches a + renderer — the failure this campaign removes elsewhere. + + So the three places an author might reach for it now answer instead of shrugging: + + - `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop. + - `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard + drill-through is **automatic**, and naming both configurable drills. + - `report.drillDown` → rejected, pointing back at the chart prop. + + ## `drillDown` is not `drilldown` + + Two capabilities, one letter apart, and they are now disambiguated in both + directions at the schema gate: + + | | `drillDown` | `drilldown` | + | -------- | -------------------------- | ----------------------------------------------- | + | spelling | camelCase | all lowercase | + | type | configuration object | boolean | + | surface | react `` prop | `ReportSchema` key (ADR-0021 D2, on by default) | + + Edit distance alone gets this wrong — the two spellings are a distance of 1, so a + plain "did you mean" would happily send an author writing `drillDown` on a report + to `drilldown`, where their config object then fails a second time as a boolean. + Both gates name the **type** difference, not just the spelling. + + ## Enforced, not just declared + + `@objectstack/lint`'s react-page publish gate now **parses** the schema + (`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal, + rather than re-deriving the rules. Unknown keys, the wrong `target`, and the + near-key spelling all fail the build with the schema's own prescription. A value + assembled from React state is skipped, unchanged: an unresolvable binding is not + a wrong one (ADR-0072 D1). + +- eaaf03c: refactor(spec,lint)!: retire the dashboard widget action trio + `aria` — and the build gate that enforced a button nobody renders (#5010, ADR-0049) + + `DashboardWidgetSchema` let an author declare a per-widget action **button** + (`actionUrl` / `actionType` / `actionIcon`) and per-widget ARIA attributes + (`aria`). None of the four reached a renderer. Re-measured 2026-08-04 across both + repos on a closed call graph: + + - **the action trio** — all 14 `actionUrl` reads in objectui's + `DashboardRenderer.tsx` are scoped to `schema.header.actions[]`, which is + `DashboardHeaderAction`, a _different_ schema. Nothing anywhere reads + `widget.actionUrl`. `actionIcon` is the starkest: zero references in either + repo outside its own declaration — not even the lint looked at it. + - **`aria`** — no consumer of `widget.aria` anywhere. The `aria-*` attributes in + `DashboardRenderer` / `DatasetWidget` are the renderer's own DOM attributes, + and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a + **view**'s. This is the dashboard-level `aria` that #3896 removed, one level + down — an accessibility guarantee an author could declare and nothing honoured. + + These four survived the #3896 sweep for the same reason `widgets[].responsive` + did, and it is not "we looked and they were live": the liveness ledger declared + no `children` on `dashboard.widgets`, so **no widget-level key had ever been + classified**. #4956 fixed that instrument and gave all 22 keys their first per-key + verdicts; this change acts on four of the six it found dead. + + ## The second-order cost this settles + + `packages/lint`'s `validate-dashboard-action-refs` enforced **ERROR-severity** + reference integrity on `widgets[].actionUrl` — a dangling target failed the + build. Its docblock called the key _"the per-widget button"_ and claimed to + mirror the objectui runtime dispatch. It did not, because that button does not + exist. So an author could be blocked from shipping because a control that cannot + render pointed at an action that also did not. + + A rule written to delete false affordances was sustaining one. That is why the + keys were retired rather than the check merely relaxed: the widget branch is + deleted, with a pin test asserting it stays silent and a second pin proving + header actions are still checked in the same stack. + + FROM → TO: + + | Removed | Replacement | + | :------------------------------- | :----------------------------------------------------------------------------------------------------- | + | `dashboard.widgets[].actionUrl` | `dashboard.header.actions[].actionUrl` | + | `dashboard.widgets[].actionType` | `dashboard.header.actions[].actionType` | + | `dashboard.widgets[].actionIcon` | `dashboard.header.actions[].icon` (the header spelling) | + | `dashboard.widgets[].aria` | **none** — delete it; author `title`/`description`, which the renderer really does label the card with | + + For a per-**row** affordance, reach for a dataset-bound `table`/`pivot` widget: + its rows are clickable and drill through the semantic layer already (no + per-widget drill config exists, by design — #5022). + + **The `AriaProps` shape is NOT removed — only this embed.** `AriaPropsSchema` / + `AriaProps` stay exported and stay live on `app.aria` and + `page.components[].aria`. Nothing importing the shape breaks. + + The retirement kit: + + - **Tombstones.** `retiredKey()` on all four, matching `responsive` in this same + schema. `DashboardWidgetSchema` _is_ `.strict()`, so a plain delete would still + be loud — but only as a generic "unrecognized key". The tombstone keeps the key + declared so the rejection carries the **prescription**, and types it `never` so + authoring it fails `tsc` first. Pins assert the message _is_ the prescription + and is _not_ `Unrecognized key`. The action trio shares one prescription that + names all three, so an author who deletes the one key they were told about does + not hit the same error twice more. + - **ADR-0087 D2 conversion + D3 chain step** + (`dashboard-widget-action-aria-removed`, `retiredFromLoadPath`): + `os migrate meta --from 16` strips the four from author sources, and stored + dashboards replay clean instead of meeting a tombstone at load. Lossless + deletes — none of the keys had an effect to lose. Its own entry rather than + more keys on `dashboard-inert-keys-removed`, whose identity is the #3896 sweep. + - **Liveness rows stay** (`status: dead`, `verifiedAt`, a REMOVED note) because + a tombstone keeps the key in the walked shape — the `rls.priority` precedent. + `authorWarn`/`authorHint` are dropped from all four: the parse owns them now. + - Baselines moved at KEY level only, as the shape's survival implies: + `authorable-surface.json` gains four `… [RETIRED]` lines; + `json-schema.manifest.json`, `api-surface.json` and + `api-surface-signatures.json` are unchanged by construction — no def stopped + being emitted and no export was removed. + + No runtime behaviour changes — that impossibility is the reason for the removal. + The one behaviour that _does_ change is a build that used to fail and now does + not. + + ## Not in this change + + `widgets[].colorVariant`, the fifth dead key #5010 lists, is **deliberately + untouched**. The rewrite target its triage assumed — `options.colorVariant` — + measured dead as well: `options` only reaches a renderer through the inline + `componentSchema` path, and `dataset` is _required_ on this schema, so every + spec-authorable widget is dataset-bound and renders through `DatasetWidget`, + which has no colour affordance at all. Moving the key would relocate 16 authored + sites (7 in `platform-objects`, 9 in `app-showcase`) from one dead slot to + another and mint a second inert key. Returned for adjudication. + +- 06ffad3: fix(lint): 字段公式校验首次对 spec 合法元数据生效 —— `f.formula` 收敛为 `f.expression` (#5026) + + `validate-expressions.ts` 的字段公式校验(`validateStackExpressions` 里的 + field-formula pass)一直读 `f.formula`。`FieldSchema` 声明的是 `expression`,而 + `formula` 恰是 `field.zod.ts:333` **按名拒绝**的别名之一 + (`aliases: { formula: 'expression', calculation: 'expression', compute: 'expression' }`)。 + 该规则以 `input: 'parsed'` 注册,compile/build/validate 路径上看到的是 + `ObjectStackSchema` 的解析产物,所以 `f.formula` 恒为 `undefined` —— + **整段检查对任何 spec 合法 stack 从未执行过一次**。 + + 这不是删死代码,是**启用一条从未跑过的检查**。字段公式从此真正受 + ADR-0032 §1a/1b 的三条判决管辖:CEL 语法、`record.` 字段存在性、 + 以及 #1928 的裸引用 / 类型健全性。对 AI 生成的元数据这一条最要紧 —— + `amount * probability`(而不是 `record.amount * record.probability`)正是公式槽位 + 最常见的错法,它在 CEL 里静默求值为 null,过去没有任何门拦得住。 + + **覆盖面扩大,但对现有元数据零新红。** 激活后在仓库全部真实元数据上实测过: + `examples/app-showcase`(3 个 `expression` 槽)、`examples/app-crm`(5 个)、 + `examples/app-todo`(0 个)全部 `ObjectStackSchema` 解析通过,新增判决 0 条; + `platform-objects`、`plugin-security` 的 default-permission-sets 不含公式字段; + `skills/` 里的公式样例全部已是 canonical 拼法。 + + **Authoring impact.** 之前拼 `formula:` 的字段本来就无法解析,schema 会按名拒绝 + 并给出 `Did you mean \`formula\` → \`expression\`?`——该行为不变,本规则不再对同一个键 + 给出第二套说法。诊断定位串同步改名以免继续传播错拼法: + + ``` + FROM object 'X' · field 'Y' formula + TO object 'X' · field 'Y' expression + ``` + + `validate-null-guards.ts` 的 surface ledger 相应把该行从 `Field.formula` 正名为 + field `expression`(`Field.formula({ expression: … })` 写入的槽)。null-guard 判决 + **仍然**排除该 surface(公式是 `value` 角色、天然可空,`guard ? value : null` 是祝福 + 写法),排除的只是 null-guard 这一条,语法 / 字段存在性 / 裸引用判决从此生效。 + + `validate-expressions.test.ts` 的 `TRACKED_UNDECLARED_READS` 记账随之清空 —— 这 + 份"只缩不长"的清单现在是零条,规则读的每一个键都是 spec 声明的键。 + +- 2f1e2a5: feat(lint): null-guard 闸门覆盖 `requiredWhen`,其余各面按"绑定是否全量"逐一定案 (#4811) + + #4763 的 null-guard 闸门只接了两面(对象校验规则、生命周期 hook `condition`), + 其余各面留作"待定"。本次把"待定"收敛成一条**可判定的判据**,并按它逐面定案 —— + 一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。 + + ## 判据:记录绑定是否对已声明字段**全量** + + 这不是口味问题,也不是"这个谓词是不是 CEL"。实测 `@marcbachmann/cel-js`,两种绑定 + 下的语义**恰好相反**: + + | 谓词 | 全量绑定 `{a: null}` | 稀疏绑定 `{}` | + | :-------------------- | :----------------------- | :--------------------- | + | `has(record.a)` | `true` ← 陷阱 | `false` ← 真守卫 | + | `record.a < record.b` | FAULT `no such overload` | FAULT `No such key: a` | + | `record.a != null` | `false` ← **修法有效** | FAULT `No such key: a` | + + 即:全量绑定下 `has()` 恒真而无用、`!= null` 是解药;稀疏绑定下 `has()` 恰恰是正确的 + 守卫,而 `!= null` **自身就会 fault**。把闸门指向一个稀疏绑定的面,等于判红正确的元数据、 + 并给出一个会把它改坏的"修法" —— 比不覆盖更糟。所以:**只有绑定全量的面才可以接入。** + + ## 纳入:字段 `requiredWhen` + + 议题没有列出这一面,而它恰恰是唯一满足判据的:`evaluateValidationRules` 用与对象校验 + 规则**同一个** `materializeDeclaredFields` 合并记录来求值 `requiredWhen`。 + + 它也是几个已覆盖面里失败得最安静的一个:`requiredWhen` 谓词 fault 时是 **fail-open** —— + `rule-validator.ts` 记一行 `failed to evaluate — skipped` 就跳过,字段于是**从未真正必填**, + 写入照常通过。校验规则至少自 #4761 起是 fail-closed 的拒绝。因此报错文案按面区分后果: + "被跳过、字段从未必填"与"写入被 fail-closed 拒绝"是两个相反的故障,作者需要知道自己 + 碰到的是哪一个。 + + ## 排除,且各自留下可引用的理由 + + - **action `visible` / `disabled`**:谓词确实走真 CEL(裸串经 `ExpressionInputSchema` + 规范成 `{dialect:'cel'}` 信封,渲染器保留它),fault 也确实 fail-closed —— 陷阱在这一面 + 是真的。但绑定是客户端已取到的那条记录(详情读取,或只带列表视图投影列的一行), + `objectui` 这条路径上不存在任何物化步骤。稀疏绑定下 `!= null` 是错的修法。要覆盖它, + 得先决定是否把该绑定做成全量 —— 那是平台契约改动,不是 lint 改动。 + - **flow / edge `condition`**:议题记的理由(扁平作用域下裸标识符可能是 flow 变量)对本 + 模块**不成立** —— 它只解析 `record.` / `previous.`,从不解析裸标识符,而引擎无 + 条件绑定这两个根。真正的阻碍还是全量性:`record-change-trigger.ts` 把记录播种为 + `{ ...inputDoc, ...after }`,没有 `materializeDeclaredFields`,所以写入未提及的已声明列 + 是**缺键**而非 null,`!= null` 会和它本要守卫的比较一样 fault。 + - **字段 `readonlyWhen`**:与 `requiredWhen` 同一个字段、相反的结论 —— 它由 + `stripReadonlyWhenFields` 求值,那里合并的是 `{ ...previous, ...data }`,从不物化。 + - **`Field.formula`**:按产品判断排除,而非按本判据。formula 是 `value` 角色、天然可空, + `guard ? value : null` 是被祝福的写法(#3306)。是否强制守卫会改变"作者被允许写什么", + 该由维护者决定,不是一个接线缺口。 + + 判据、实测表与逐面台账写在 `validate-null-guards.ts` 的模块注释里,每条排除在它对应的 + 调用点也留了注释,并各配一条断言钉住。 + + ## 顺带修正:`field '?'` + + 诊断的字段名此前走 `Object.values(fields)`,把**名字键**丢掉了 —— 而名字键正是 + `Field.text({…})` 这种(最常见的)写法产生的形状,于是这类对象上的每条字段级诊断都定位在 + `field '?'`。名字只出现在 `where` 里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。 + +- af96af6: fix(lint): `validateOrgAxisRedLines` reads the sharing-rule keys the spec declares — the ADR-0105 D6 red lines fired on nothing before (#4984) + + The two ADR-0105 D6 red lines are declared `error` and gate `os validate` / + `os build` / `os lint`. On the sharing-rule path neither of them could fire. + + `validateOrgAxisRedLines` read `rule.criteria ?? rule.filter` and + `rule.sharedTo ?? rule.recipient`. `SharingRuleSchema` is `.strict()` and its + declared keys are `condition` and `sharedWith`; all four names it was reading + exist only as **rejected aliases** in `sharingRuleUnknownKeyError`, the + prescription attached to the refusal message. The rule runs on the post-parse + stack (`input: 'parsed'`), so for every spec-valid stack those four properties + were `undefined`, `JSON.stringify(undefined ?? '')` was `'""'`, and the + `parent_organization_id` test was constantly false. + + **This is a behaviour change: the rule previously never triggered.** Both red + lines are now live on the sharing-rule path: + + | Authored shape | Before | After | + | :--------------------------------------------------------------------------- | :----- | :------------------------------------------------------------------------ | + | `condition` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].condition` | + | `sharedWith` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].sharedWith` | + | `sharedWith: { type: 'business_unit' }` on a `tenancy.enabled: false` object | passed | `error` `org-axis-cross-org-bu-grant` at `sharingRules[i].sharedWith` | + + A stack that ships today keeps building unless it contains one of those three — + the shapes D6 forbids and the gate was meant to have been refusing all along. + The rejected aliases are deliberately **not** read: a rule spelling `criteria` + or `sharedTo` is refused by the schema's own parse with the canonical key + named, and a consumer must not tolerate what the producer's contract rejects. + + `condition` is an `ExpressionInput`, so all three of its shapes are scanned — + the authored bare string, the parsed `{ dialect, source }` envelope, and the + compiled `{ dialect, ast }` form. + + The rule's own tests were the reason this survived review: their fixtures used + the same rejected aliases, so the suite was green while the gate was dead. Every + sharing-rule fixture now goes through `SharingRuleSchema` before the lint sees + it, and every object fixture through `ObjectSchema` — a fixture that drifts from + the spec surface fails at the fixture instead of silently exercising a shape no + author can write. + +- 5b8f95b: fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (#4889) + + `readonlyWhen: P\`parent.status == 'paid'\``— the documented "once the header +invoice is Paid, its lines are frozen" lock — was enforced **only in the client +grid**. The server-side strip bound`record`and`previous`and had no`parent`at all, so every parent-scoped predicate faulted, took the fail-open branch, and +the write landed anyway. On the reference app that meant one`PATCH` rewrote the + quantity and unit price of a settled invoice's line: HTTP 200, value persisted, + the grid still drawing the cell read-only. ADR-0057 D10 puts enforcement on the + server and makes the client courtesy; here only the courtesy layer enforced. + + **`parent` is now bound on the write path.** For a detail object — one declaring + exactly one `master_detail` relationship — the engine resolves the master record + and binds it as `parent` before the strip runs, on both the single-id and the + bulk (`multi: true`) update paths. A repointing write is judged against the + master it _lands on_, not the one it leaves. The read is gated on the payload + actually touching a parent-scoped predicate (decided from the parsed CEL AST, so + a field named `parent_id` costs nothing), and the bulk path batch-reads the + distinct headers in one query rather than one per row. + + **An unbindable scope no longer waives the lock.** A `readonlyWhen` that names a + root the operation could not bind now resolves to **locked** — the field is + stripped — instead of "not locked". "The platform could not check this" must not + mean "allowed" on a field the author declared frozen. This is deliberately the + narrowest possible carve-out from the fail-open policy the strip has always had: + a predicate that is merely _broken_ on the record (undeclared key, `null` + ordering overload, parse error, engine throw) still fails open exactly as + before, and `requiredWhen` / option `visibleWhen` are untouched. Recorded as an + addendum to ADR-0058's D5 fail-policy matrix, alongside the same narrowing + already made for validation predicates (#4649) and hook conditions (#4775). + + **And the runtime branch is a backstop, not the plan.** `objectstack compile` + now **rejects** a `parent`-scoped `readonlyWhen` on an object that declares no + `master_detail` relationship, or two of them (where the metadata does not say + which one is "the parent" and picking by declaration order would make a + data-integrity lock depend on field ordering). The common authoring mistake is + caught where it is cheap to fix, so it never reaches a runtime that has to judge + it — declared, not guessed. + + No metadata changes are required: an app whose parent-scoped locks were already + correct simply starts having them enforced. If you authored one on an object + with no single master, the build now names it. + +- bf1edef: feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983) + + `isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4: + "exposed so an authoring-time gate (`objectstack compile`) can REJECT a + predicate the runtime would silently drop … A `false` here means 'this + predicate will never enforce'." It had **no non-test consumer anywhere** — the + function written to fix declared-but-never-read was itself declared and never + read. This lands the consumer, in two steps that had to happen in this order. + + **1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM + `@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula` + (`src/rls-predicate.ts`), and are exported from its root.** Executable code + unchanged — a change of address, not of behaviour; `plugin-security` now imports + them from `@objectstack/formula` and keeps no copy, so there is still exactly + one definition. No import path outside the two packages changes: neither symbol + was ever exported from `@objectstack/plugin-security`'s entry point. The move is + what makes step 2 possible at all — `@objectstack/lint` may depend on + `@objectstack/spec` and never on a runtime, so with the predicate living in a + runtime the gate's only other door was copying the SQL→CEL bridge, whose + boundary conditions (quoted literals are never rewritten; canonical CEL passes + through unchanged) _are_ the gate's red/green line. A fork drifting by one + character rejects policies the runtime executes correctly — the false-positive + direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical + shape gate; the bridge is part of that gate. + + **2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`, + `error`, on all three authoring commands**, over + `permissions[].rowLevelSecurity[].using` and `.check`: + + - **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown + subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a + cross-object path (`record.account.region`). + - **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy + SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery. + Its own id because the fix is different — write CEL (`&&`, `||`), not a + different shape. + + What the gate prevents, measured through `plugin-security` rather than inferred: + `RLSCompiler` drops the policy and logs one request-time WARN. On the read path, + when it is the only applicable policy, `compileFilter` returns the + `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so + every select / update / delete on the object matches **zero rows**. On the + ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which + no record satisfies, so every insert / update fails with `PermissionDeniedError`. + The runtime fails closed, which is why this was survivable: the result is not a + hole but a policy that reads as an authorization and behaves as a blanket + refusal, with nothing at authoring time pointing at the line that caused it. + + Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=` + `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and + `startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058 + D2), against a literal or a `current_user.*` value. Two specific migrations: + `has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object + _validation_ rule, which is interpreted, and wrong here, where the predicate is + compiled to a filter); and a related record's field → denormalise it onto this + object (formula/rollup) and test that column, since RLS cannot join (ADR-0055). + + Same construction as the sharing-rule gate (#4698): the rule does not model the + consumer or grep for it — it calls `isSupportedRlsExpression`, the exact + function `RLSCompiler.compileFilter` consults to decide whether a dropped policy + earns its warning, so the two verdicts are one boolean by construction, pinned + in both directions over a shared corpus. Measured before shipping: every RLS + predicate declared anywhere in this repo — the `plugin-security` platform seeds, + the examples, the dogfood fixtures, the authoring skill — is supported, so the + gate turns nothing red that works today. Unlike the sharing-rule gate, CEL + _syntax_ is reported here rather than deferred to `expression-invalid`: + `validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not + judge this field correctly if it did, because `owner_id = current_user.id` is a + CEL syntax error and a working RLS predicate at the same time. + +- ddd6650: feat(lint): reject a sharing-rule condition the runtime can only skip (#4698) + + #4698 reported the same failure shape three times in one app in one day: a key + that is authored, is schema-valid, reads as meaningful — and is never consumed + by the runtime. Every check verifies that what is declared is _well-formed_, + never that it is _read_. The issue's third measured instance is a sharing rule + whose CEL `condition` uses `has(...)`: the seeder cannot lower it, skips the + rule, and the only signal is one WARN line at boot. The rule exists in + metadata, is absent from `sys_sharing_rule`, and grants nothing. + + **New rules, both `error`, on all three authoring commands:** + + - **`sharing-rule-unlowerable-condition`** — the condition is outside the + pushdown subset: a function call (`has(...)`, `size(...)`), arithmetic, a + ternary, or a cross-object path (`record.account.region`). + - **`sharing-rule-runtime-variable-condition`** — the condition reads + `current_user.*`. Criteria sharing rules are materialised (one static + `criteria_json` per rule, from which grants are written), so there is no + "current user" at compile time. The fix is a different mechanism, not a + different spelling, which is why it has its own id. + + Fix each by rewriting the predicate inside the lowerable subset — `==` `!=` + `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and + `startsWith` / `endsWith` / `contains` over single-column `record.` + paths (ADR-0058 D2). Two specific migrations: `has(record.x)` → `record.x != +null` (`has()` is correct in an object _validation_ rule, which is + interpreted, and wrong here, where the condition is compiled); and a related + record's field → denormalise it onto this object (formula/rollup) and test + that column, or share the related object instead. For per-user access, use an + RLS policy (`rowLevelSecurity[].using`), where `current_user.*` _is_ resolved. + + **Why this one surface and not "unread keys" in general.** "Is this key read?" + is only a lint question when the answer is computable from the authored + metadata alone, and usually it is not — a repo-wide grep for a reader is not + evidence of absence, and a consumer may live in another package, another repo, + or an uninstalled plugin. A sharing rule's `condition` is the case where the + predicate is exact: its one runtime consumer + (`bootstrapDeclaredSharingRules`) does exactly one thing with the key — + `compileCelToFilter(condition, { variables: {} })` — and a condition that does + not lower means the rule is skipped outright. So the lint calls that same + compiler, from the same package, with the same options, instead of modelling + the consumer; the verdict is identical to the seeder's by construction and is + pinned in both directions by a test over a shared corpus. + + `error` rather than advisory, per the ADR-0078 claim `SharingRuleSchema`'s own + docblock makes ("the whole authorable surface is enforced — nothing here + validates and then silently does nothing"): there is no reading under which an + unlowerable condition does what it says. It fails closed, which is why it was + survivable, not why it was acceptable. Measured before shipping: every + sharing-rule condition declared anywhere in this repo lowers cleanly, so the + gate turns nothing red that works today. + + CEL _syntax_ errors are deliberately left to `expression-invalid`, which + already gates this same field with a message written about syntax. + +- 1e6ab15: feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b) + + - **New rule `unique/unscoped-declared-index`** (warning, advisory): a declared + index with bare `unique: true` — the spelling whose scope is unstated, the + #4986 trap. Fires on the spelling alone (no tenancy/posture inference — + `organization_id` is kernel-injected at registration, so an authoring-time + guess would be wrong half the time; see #4698). The fix names both words: + `'global'` (installation-wide — exactly today's behavior) or + `'organization'` (one holder per organization). Protocol 18 rejects the + spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` + + `UNIQUE_UNSCOPED_DECLARED_INDEX`, registered as its own AUTHORING_RULES + entry (validate/build) and called by `lintDataModel` for `os lint`, so all + three commands report it — each finding exactly once. + - **R10 `unique/double-declaration` rewritten as the four-quadrant scope + matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'` + (or bare `true`, its deprecated spelling) on the same single column = + CONTRADICTION (the installation-wide index wins physically; the + per-organization intent is silently dead) — and the mirror, field `'global'` + × declared `'organization'`, likewise; same scope on both sides = REDUNDANCY + (the same index declared twice). The old field-`'global'` exemption is gone + (now reported as redundancy), and the fix text replaces the hand-written + `fields: ['organization_id', …]` advice with the `'organization'` spelling — + the hand-written composite is not NULL-safe (#5030). + +### Patch Changes + +- c1e67e0: fix(lint): 扩 ADR-0105 D6 ② 的收件人词表至 ADR 原文范围 —— `unit_and_subordinates` + 也判红 + + `org-axis-cross-org-bu-grant`(D6 ②)此前只对 `sharedWith.type === +'business_unit'` 判红,而授权面**更大**的另一个业务单元收件人 + `unit_and_subordinates`(一个 BU **加上其全部后代单元**,ADR-0057 D5 子树扩张) + 直接放行。两者的缺陷完全相同:平台级对象(`tenancy.enabled: false` / + `systemFields.tenant: false`)没有 organization 列可供 Layer 0 收口,BU 子树没有 + 任何 organization 可供解析,授权因而跨到库里每一个 organization —— 正是 ADR 拒绝 + 的"跨 org BU 巨树",从后门到达。 + + 漏掉的恰恰是 ADR-0105 D6 ② 自己点名的那一个: + + > Every BU mechanism — `unit_and_subordinates` sharing, `adminScope` + > delegation, depth scopes — operates within one organization. There is no + > cross-org tree. + + 判定改为收件人类型 ∈ `{ business_unit, unit_and_subordinates }`,诊断信息里点名 + **实际写下的**类型并说明其触及范围(子树那一个额外写明 "AND every descendant + unit"),修复建议改为指向三个扁平收件人。 + + 词表与 spec 枚举 `ShareRecipientType` 的差集不再是隐式的:规则里以表格逐条写明 + 拦截二者、放行 `user` / `team` / `position` 的理由(它们的运行时展开都不经 + `BusinessUnitGraphService`,是 `tenancy.enabled: false` 平台级目录**被设计用来** + 共享的方式),并附一条测试断言两半恰好划分 `ShareRecipientType` —— 将来枚举加成员 + 会在词表处失败,而不是无声地落进没人选过的那一桶。#4991 正是这条断言缺席的产物。 + + 这是 error 级门禁的扩张,因此复核了真实元数据:`examples/app-showcase` / + `app-crm` 是仓库里仅有的已声明 sharing rule(共 11 条),全仓无任何对象关掉 + tenancy,扩张后 org-axis 红线数为 **0** —— 不产生新红。 + +- 641363a: fix(docs,lint): 修正两处裸引用公式样例,并给公式样例补一道 CEL 语义门 (#5116) + + #5026 把字段公式校验从 `f.formula`(spec 按名拒绝的别名)收敛到声明的 + `f.expression`,**激活了一条从未跑过的检查**。它的真实元数据扫描顺带发现文档和博客 + 里有两处公式样例写的是**裸引用**: + + - `content/docs/data-modeling/fields.mdx` — `'quantity * price * (1 - discount / 100)'` + - `content/blog/context-window-is-the-constraint.mdx` — `` cel`amount * probability` `` + + 裸引用在 CEL 里不报错,而是**静默求值为 null**:公式表达式把记录绑定在 `record` + 命名空间下,顶层的 `quantity` 什么也解析不到。照这两行写出来的元数据,在 #5026 + 之后会被 `os build` / `os validate` 判红 —— 文档教的写法和平台的门直接矛盾。两处 + 都已改成 canonical 的 `record.` 前缀形式。 + + **新增 `@objectstack/lint` 的 `check:doc-formula-expressions`**,堵住让这两条长期 + 存活的那个洞。`check:doc-authoring` 看的是字面量的**形状**,`check:skill-examples` + 对标记块跑 `tsc --noEmit` —— 两者之间,"能编译但 CEL 写错"的样例没有任何门: + `expression` 的类型就是 `string`,`'quantity * price'` 和 + `'record.quantity * record.price'` 编译得一样好,而只有后者能用。 + + 判决直接 import `@objectstack/formula` 的 `validateExpression` —— 和 `os build` + 走的是同一个调用,不是仿制品。于是文档是被**规则本身**把关,而不是被规则的一种方言 + 把关(Prime Directive #12)。 + + 门的难点不在判决而在**判据**:同一个 `expression:` 键在语料里承载至少三种互不相干的 + 契约 —— 记录作用域的 CEL 公式、flow 的扁平作用域谓词(那里裸引用是**对的**)、以及 + `schedule` 下压根不是 CEL 的 cron 串。按键名匹配会把后两类全部误判为红。所以它只认 + **解析后的结构**,且只认两种不可能有歧义的形状:`Field.*({ expression })`,以及 + `type: 'formula'` 与 `expression` 并列的对象字面量。无法提取的块**报错而不是跳过** + ("absence must be loud")。 + + 覆盖面是明说的,不含糊:只看 TS/TSX 代码块、只看能静态取出的表达式源、不做字段存在性 + 校验(文档片段没有对象声明)、flow / action / validation 谓词**刻意不在范围内**。 + +- 5d3ced9: fix(objectql,lint)!: a `json_schema` validation rule's `format` keyword is now ENFORCED — records that passed before can start failing (#5029) + + > **⚠️ BEHAVIOUR CHANGE ON DEPLOYED DATA — READ BEFORE UPGRADING.** A `format` + > keyword inside a `json_schema` validation rule used to enforce **nothing**. + > It now enforces. If any deployed object carries such a rule, writes that + > succeeded on the previous version can be **rejected** after this upgrade — + > including writes from flows, seeds, imports and integrations, not just the UI. + > Nothing about the metadata changed; the runtime simply started honouring what + > the metadata always said. See "Before you upgrade" below. + + ## What was broken + + `packages/objectql/src/validation/rule-validator.ts` built its shared ajv as + `new Ajv({ allErrors: true, strict: false })` and stopped there. In ajv 8 the + `format` keyword is **not built in** — it ships in the separate `ajv-formats` + package — and under `strict: false` an unregistered format is not an error: ajv + logs one line at compile time and **drops the keyword**. + + So this rule: + + ```ts + { + type: 'json_schema', + name: 'support_config_shape', + field: 'support_config', + message: 'Support config is invalid.', + schema: { + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'], + }, + } + ``` + + compiled fine, ran on **every** write, enforced `type` and `required` — and + enforced **nothing at all** for `format`. `{ email: 'not-an-email' }` was + accepted, for every record, forever. The only signal was a stderr line at + compile time naming no rule and no object. + + This is the #4649 / #4762 family one level in, and the partial failure is what + made it nasty: the rule visibly rejects a bad `type` / missing `required` + payload in dev, so it reads as _working_ while the `format` half never fires. + `format` is also one of the most reached-for JSON Schema keywords (`email`, + `uri`, `uuid`, `date`, `date-time`, `ipv4`), so this was not an exotic corner — + and it is exactly the shape an AI writing metadata reaches for first. + + ## What changed + + - **`@objectstack/objectql`** now depends on `ajv-formats` and registers it on + the shared instance (`addFormats(ajv)`). The **default (full)** format set is + used deliberately: `fast` mode trades correctness for speed on precisely the + formats authors reach for most, and a format that "mostly" matches is the same + declared ≠ enforced defect with a smaller hole. + - **`@objectstack/lint`** — the #4762 publish gate + (`validate-rule-compilability.ts`) compiles every `json_schema` rule with the + SAME ajv environment the runtime uses, on purpose, so it registers the same + plugin. This is not cosmetic parity: `ajv-formats` also installs the + `formatMinimum` / `formatMaximum` keywords, so a gate without it treats them + as unknown keywords (`strict: false` ⇒ silently ignored) and would publish a + schema the runtime then refuses to compile — a rule that passes review and + enforces nothing, which is the failure that gate exists to prevent. The parity + test now reads the plugin registration out of the runtime's source, so the two + cannot drift apart silently. + + **Authoring is unchanged.** `format` stays a legal, publishable JSON Schema + keyword; the publish gate does not refuse it (option 2 on #5029 was considered + and rejected — refusing standard JSON Schema would push authors into private + spellings). What changed is only that the declaration is now true. + + ## Before you upgrade + + 1. Find the rules at risk: any `object.validations[]` entry with + `type: 'json_schema'` whose `schema` contains a `format` key, at any depth + (including inside `$defs` / `$ref` and a `conditional`'s `then` / + `otherwise` branch). + 2. For each, audit the existing column against that format. Rows already stored + are **not** re-validated — nothing is rejected retroactively, and no + migration runs — but the **next write that touches the field** is checked, + which includes an unrelated PATCH that merely resends the JSON blob. + 3. If a format was aspirational rather than real, remove that `format` key (or + relax it) _before_ upgrading. Deleting the keyword is now a meaningful, + visible act rather than a no-op. + + ## Known limitation, recorded deliberately + + A **misspelled** format name is still ignored. `format: 'emial'` compiles under + `strict: false` — ajv logs `unknown format "emial" ignored` and drops it — in + both the runtime and the publish gate, so a typo still enforces nothing. That + behaviour is unchanged here and pinned by test in both packages, so it is a + known boundary rather than an oversight; closing it is an authoring-time + decision of its own and is tracked separately. + +- 461ccda: fix(lint): 收敛 `validateStackExpressions` / `validateSecurityPosture` 里读 spec 不声明键的 `??` 别名链 (#5017) + + 两条规则都以 `input: 'parsed'` 注册,看到的是 `ObjectStackSchema` 解析后的产物。 + #4984 → #5009 清掉了 sharing rule 字段层和 org-axis 规则里的同形读法;这一轮是同族 + 第三轮,落在另外两个文件。议题点名五条,全包 grep 又找出同形的两条,一并处置: + + | 原读法 | spec 事实 | 处置 | + | :-------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :----------------------------- | + | `rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula`(两处) | 四个别名全是 `validation.zod.ts` 的 `aliases: { …: 'condition' }` **按名拒绝**的键;canonical 排第三 | 收敛为 `rule.condition` | + | `obj.validations ?? obj.validationRules` | `ObjectSchema` 只声明 `validations`,strict 按名拒绝 | 收敛为 `obj.validations` | + | `rule.condition ?? rule.criteria ?? rule.predicate` | `criteria` 是运行时编译产物 `criteria_json` 的拼法(#3896),`predicate` 直接拒绝 | 收敛为 `sharingRule.condition` | + | `def.reference ?? def.referenceTo` | `field.zod.ts:331` 把 `referenceTo` 映射为 `reference` | 收敛为 `def.reference` | + | `action.objectName ?? action.object` | canonical 是 `objectName`;`object` 按名拒绝 | 收敛为 `action.objectName` | + | `obj.sharingModel ?? (obj.security)?.sharingModel` | **`ObjectSchema` 根本没有 `security` 键** —— OWD 三个拨盘是平铺的,且 strict:嵌套写法被整包拒绝 | **删除整个 fallback** | + | `def.reference ?? def.reference_to` | 同 `referenceTo` | 收敛为 `def.reference` | + + 对任何能解析的 stack,判定结果不变 —— 三个 example(crm / showcase / todo)与平台 + default permission sets 上,改动前后两条规则的 findings 逐字相同。 + + **其中一条不是死代码,是活着的错。** `rule.expression ?? … ?? rule.condition ?? …` + 把 canonical 的 `condition` 排在两个被拒别名之后,所以一条同时写了 `condition` 和 + `expression` 的规则,lint 校验的是 schema 会拒绝的那个,而作者声明的那个**从头到尾 + 没被看过**:producer 和 consumer 对同一份元数据给出两套说法。测试里重建了旧链来演示 + 这个差异,而不是只描述它。 + + 真正的代价从来不是漏报,而是误导 —— `object.security.sharingModel` 出现在**安全 + linter**里,足以让下一位作者(人或 AI)相信对象级 `security` 信封是真实的授权面。 + + 同时补上两层结构性 meta-guard(#4992 模式,#5018 形状),让下一条死读法在 review + 前就红: + + - **declared-key guard** —— 规则源码里从每个 surface 上读的键,必须出现在该 surface + 自己的 Zod `.shape` 里。扫源码不是扫行为是刻意的:不可达分支没有行为可断言。 + - **reachability guard** —— `validateSecurityPosture` 全部 15 个 `findings.push` + 落点都必须被一条 schema **不报 `unrecognized_keys`** 的 fixture 触达。判据不是 + #5018 的 `safeParse` 全绿,而这正是这条规则的特点:它被文档明确设计为也跑在 + parse 前,好让 `os lint` 对 zod 会拒绝的**值**(`sharingModel: 'read'`)给出更 + 好的信息。被拒的**值**和被拒的**键**是两回事 —— 后者在 parsed 路径上压根到不了。 + + 七条读法各自做过变异测试:任意一条加回去,都至少有一条测试转红。 + +- f238970: fix(lint): 收敛 `validateRuleCompilability` 里读 spec 不声明键的 `??` 别名链 (#5096) + + #4984 → #5009 → #5017/PR #5046 同族第八处,落在第三个文件 + (`validate-rule-compilability.ts:239`): + + | 原读法 | spec 事实(对 live `.shape` + `safeParse` 实测) | 处置 | + | :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- | + | `obj.validations ?? obj.validationRules` | `ObjectSchema.shape` 只声明 `validations`,且 strict —— `validationRules` 被**按名拒绝**:`Unrecognized key(s) on this object: \`validationRules\`. … Did you mean \`validationRules\` → \`validations\`?`(#4001) | 收敛为 `obj.validations` | + + 该规则以 `input: 'parsed'` 注册,canonical 排首位,所以别名 limb 对任何能解析的 + stack 都不可达 —— 三个 example(crm / showcase / todo,共 28 个对象、17 条验证规则) + 上改动前后 findings **逐字相同**(两侧均 0 条)。 + + **代价从来不是漏报,而是误导。** 一个写在 consumer 里的别名 fallback,等于向后来的 + 读者、以及照着这份源码写元数据的 AI 宣称 `objects[].validationRules` 是一个真实的 + authoring 面;它把 schema 一句指名道姓的拒绝,降级成一条静默失效的分支。别名容忍属于 + producer 的拒绝面,不属于 consumer(Prime Directive #12)。 + + 同时给本文件补上两层结构性 meta-guard(#4992 模式,#5017 形状),让下一条死读法在 + review 前就红: + + - **declared-key guard** —— 规则源码里从 `stack` / `obj` / `rule` 上读的每个键,必须 + 出现在对应 surface 自己的 Zod `.shape` 里,且 `expected` 精确匹配;另加一条 "covers + every receiver" 元测试,以及一条针对 `flattenRules` 里 `rule[branch]` **计算属性** + 读法的专项断言(点号扫描看不见它,而 `then` / `otherwise` 恰是本规则最有意思的读法)。 + - **reachability guard** —— 两个 `findings.push` 落点都必须被一条 `ObjectStackSchema` + **完整 parse 通过**的 fixture 触达。这里用的是 #5018 的 `safeParse` 全绿判据,比 + `validate-security-posture` 只能要求"不报 `unrecognized_keys`"更严一档 —— 因为本规则 + 判的是"编译不过",而在 spec 眼里 `regex` 是任意字符串、`schema` 是任意 record,编不过 + 的产物依然完全 spec 合法:这条 gate 存在的理由正是 zod 看不见该缺陷,所以它永远不需要 + 一条 zod 会拒绝的 fixture。 + + 原测试 `reads \`validationRules\` too` 断言的正是被删掉的那条 limb,实测确实产出 1 条 + finding(不是空转),因此它被**替换**而不是改拼写:把 key 换成 canonical 只会留下一条 + 主题已不存在的绿测试。变异测试:别名 limb 加回去 → 2 条红;改成纯别名读 → 11 条红。 + +- 471839d: fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956) + + The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live` + verdict plus a `note` asserting that the per-widget props were _"classified in + the DashboardWidgetSchema subtree"_. **No such subtree ever existed.** The gate's + walk drills one level and only through an explicit `children`, and `widgets` + declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema` + were never classified, never counted as unclassified, and every run printed + "all governed-type properties are classified" anyway. + + That gap — not evidence — is what carried `widgets[].responsive` through the + #3896 inert-key sweep that removed both its sibling `widgets[].performance` and + its literal namesake `view.responsive`. `view` is drilled through `children`, so + `list.responsive` got asked and went out; `widgets` was never asked. It was + finally retired in #4876 / PR #4995, by hand, four days late. + + **What changed for authors** + + The `objectstack build` / `objectstack lint` advisory now covers dashboards, so + five widget keys warn at build time (they never did before — `dashboard` was not + in the lint's type collections, because until now its ledger warned on nothing): + + | Widget key | Why it warns | What to do instead | + | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------- | + | `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance | + | `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` | + | `widgets[].actionType` | pairs with the above | as above | + | `widgets[].actionIcon` | zero readers in either repo | as above | + | `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` | + + Advisory only — the build never fails on these. **Nothing is removed and no + runtime behaviour changes**: this records verdicts, it does not act on them. + Enforce-or-remove (ADR-0049) for the five is tracked separately. + + Two verdicts worth knowing because they cut the other way: `requiresService` is + **live** — it reads as inert in the renderer repo but the REST layer strips + widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live + on the inline chart path only; on the ADR-0021 dataset path the string arms are + dropped and `{ offset }` fails in the analytics executor. + + **What changed for the gate** + + `pnpm --filter @objectstack/spec check:liveness` gains a third direction. A + ledger entry sitting on a container property must now declare one of exactly + three dispositions, all of them data: **drilled** (`children`), **deferred** (a + `{ container, to }` row naming the coordinate that does classify the subtree), + or **recorded** (a row in the shrink-only + `scripts/liveness/undrilled-containers.baseline.json`). A container in none of + the three fails, and so does a baseline row whose container has since been + drilled. + + A deferral is **resolved, not believed** — the target must exist (a governed + type root, or a drilled `type/prop` coordinate) and classify exactly the + container's child keys; a dangling or drifted target fails. That is the #4956 + claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema` + now produces a build failure naming it, where the same words in a `note` were + believed for a release. + + Every run reports both populations (today: 58 containers / 292 child keys + classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled` + prints the worklist, and the success line no longer claims a completeness it + does not have. + +- b821b29: fix(lint): 清除 `validateOrgAxisRedLines` 里 spec 合法 stack 永远到不了的四条分支 (#5009) + + `validate-org-axis-red-lines.ts` 是 `input: 'parsed'` 规则 —— 它看到的是 + `ObjectStackSchema` 解析后的产物。#4984 修掉了 sharing rule 字段那一层的 `??` + 别名读法,但同一文件里还留着四条同形分支,每一条读的键 spec 都不声明: + + | 原读法 | spec 事实 | 处置 | + | :-------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------ | + | `cfg.permissions ?? cfg.permissionSets` | stack 根 **strip** 未声明键,`permissionSets` 解析后必为 `undefined` | 收敛为 `cfg.permissions` | + | `cfg.sharingRules ?? cfg.sharing`(两处) | 同上 | 收敛为 `cfg.sharingRules` | + | `str(rule.object ?? rule.objectName)` | `SharingRuleSchema` 是 `.strict()`,按名拒绝 `objectName`;`object` 又是必填 | 收敛为 `rule.object` | + | `asArray(object.rowLevelSecurity ?? object.rls)` 整段(约 20 行) | **`ObjectSchema` 两个键都不声明**,且 `.strict()` —— 带对象级 RLS 的 stack 在 `os validate` / `os build` 直接被拒("Unrecognized key(s) on this object") | **删除** | + + 对任何 spec 合法的 stack,判定结果不变:这些分支本来就永远不执行(反向验证 —— + 新测试跑在改动前的实现上,29 条由 `safeParse` fixture 驱动的断言全绿)。真正的 + 代价从来不是漏报,而是误导:对象级 RLS **根本不是授权面**(`authorable-surface.json` + 里只有 `security/PermissionSet:rowLevelSecurity` 一条),而那段死代码连 + `objects[N].rowLevelSecurity[M].using` 的诊断 path 都写好了,足以让下一位作者 + (人或 AI)相信它是真的并照着写更多代码 —— #5008 差点就这么做了。 + + 行为上唯一的差别落在 `os lint`(不 parse,跑 normalized 层):把别名拼法写进 + stack 的作者,不再从这条红线拿到诊断,而是从 schema 那里拿到一条指名道姓的 + 拒绝。别名容忍属于 producer 的拒绝,不属于 consumer(Prime Directive #12)。 + + 同时补上一层结构性 meta-guard(#4992 模式),让下一条死分支在 review 前就红: + + - **declared-key guard** —— 规则源码里从 stack / permission set / RLS policy / + object / sharing rule 上读的每一个键,都必须出现在对应 schema 自己的 `.shape` + 里。扫源码而不是扫行为是刻意的:不可达分支根本没有行为可断言。 + - **reachability guard** —— 每个 `findings.push` 调用点都必须被至少一条过 + `safeParse` 的 fixture 触达;走不到的分支不允许存在。 + - 规则 ① 的 fixture 现在也走 `PermissionSetSchema.safeParse`(此前只有 sharing + rule 和 object fixture 有这层保护)。 + + 四条分支各自被变异测试验证过:把任意一条加回去,都至少有两条测试转红。 + +- da1a64c: fix(lint): reject a validation rule whose regex or JSON Schema does not compile, at authoring time (#4762) + + Two of the six object validation-rule types carry a **static artifact** that the + write path hands to a real compiler, inside a `try/catch` that logs and returns + `null`: + + - `format` → `new RegExp(rule.regex)` → _"Validation rule '…' has an invalid regex — skipped"_ + - `json_schema` → `ajv.compile(rule.schema)` → _"Validation rule '…' has an uncompilable JSON Schema — skipped"_ + + "Skipped" means the rule is declared, appears in the metadata, appears in every + "what protects this object" listing — and enforces nothing, on every record, for + as long as the metadata is deployed, with a WARN line in a log nobody reads as + the only signal. That is the shape #4649 was filed about one rule type over; + #4761 flipped the CEL predicates to fail closed and deliberately left these two, + because their blast radius differs (see below). + + **New gate — `validateRuleCompilability`**, a `gating` entry in + `AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`, + `os build`, `os lint`) with no per-command wiring. Two rule ids: + + | id | fires when | + | :----------------------------------------- | :----------------------------------------------------------- | + | `validation-rule-regex-uncompilable` | a `format` rule's `regex` throws in `new RegExp(...)` | + | `validation-rule-json-schema-uncompilable` | a `json_schema` rule's `schema` throws in `ajv.compile(...)` | + + Each finding names the rule, the object and the config path, and carries the + **compiler's own error text verbatim** — an author cannot act on "invalid + regex", but can act on `Invalid regular expression: /([/: Unterminated character +class`. Rules nested in a `conditional`'s `then` / `otherwise` are judged too + (`evaluateRule` recurses into them and reaches the very same checkers), and the + finding names the branch it is in. + + **Detection is the real compilers, never a pattern that judges a pattern.** The + regex is compiled with `new RegExp(source)` — the exact call `checkFormat` + makes. The schema is compiled with ajv constructed with the **same options the + runtime's shared instance uses** (`{ allErrors: true, strict: false }`), read + back out of `rule-validator.ts`'s source by a parity test so the day those + options change, this gate is told rather than left quietly disagreeing. + `strict: false` is load-bearing in both directions: a gate running `strict: true` + would reject author-written schemas carrying vendor keywords that the write path + compiles happily — a gate that turns working metadata red gets switched off, and + then protects nothing. + + `ajv` is a new dependency of `@objectstack/lint`, loaded **lazily**: only a + stack that actually declares a `json_schema` validation rule pays for it, pinned + by the package's `lazy-deps.test.ts` alongside `typescript` and `sucrase`. The + kernel boot path (`@objectstack/lint/runtime`) never loads it at all. + + **The runtime half is deliberately unchanged.** `rule-validator.ts` still fails + open on both, and the `#4649 — unchanged neighbours` pins that record it stand + exactly as they are. A broken regex or schema is _static_ — decidable from the + metadata alone, with no record in hand — so the authoring door closes the class + outright without ever bricking a running deployment, whereas rejecting at write + time would reject **every** write touching that field for as long as the bad + metadata is deployed. Whether a runtime backstop is still wanted on top of a + closed authoring door stays open on #4762. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + - @objectstack/sdui-parser@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index fc0be6b722..c5f3d61b78 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "license": "Apache-2.0", "description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 94dbf52b95..b3141ee8e6 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,60 @@ # @objectstack/plugin-mcp-server +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index d6b1f2d611..06ef7323af 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 279d9c4cfa..9c35cde9d9 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/metadata-core +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index e1ee72fa93..fa64eacd17 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.2", + "version": "17.0.0-rc.3", "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 93388e8ba0..cb9c61ac19 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,11 @@ # @objectstack/metadata-fs +## 17.0.0-rc.3 + +### Patch Changes + +- @objectstack/metadata-core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 20f27c169a..7639b3211f 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.2", + "version": "17.0.0-rc.3", "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 3876c19cf6..dae80e6e04 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,685 @@ # @objectstack/metadata-protocol +## 17.0.0-rc.3 + +### Major Changes + +- f61c8cf: feat(spec,metadata-protocol)!: a sort node spelling its direction `direction` is a 400, not a silently reversed page (#4721) + + **FROM → TO:** `orderBy: [{ field: 'updated_at', direction: 'desc' }]` → + `orderBy: [{ field: 'updated_at', order: 'desc' }]`. One word. If you are on the + `{field, direction}` shape because you moved code over from + `IReportService.orderBy`, that contract is unchanged — it is `orderBy` on the + QueryAST / `EngineQueryOptions` axis that has always been `{field, order}`. + + ## What was wrong + + `SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied. + Measured on `main` before this change: + + ``` + SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' }) + → { field: 'updated_at', order: 'asc' } + ``` + + `direction` was discarded and `order` fell back to its `asc` default. The sort + therefore ran in the **opposite** direction and the request succeeded. Paired + with `limit` — which is how a caller asks for "the latest N" — that is not a + reordered page but a **different set of rows**, returned under an ordinary 200 + with nothing in the response to distinguish it from the answer that was asked + for. + + `direction` is not a typo. It is the live vocabulary of a neighbouring contract, + `IReportService.orderBy` (`@objectstack/spec/contracts`), and + `plugin-auth/objectql-adapter.ts` already translates between the two by hand — a + translation known to be necessary and enforced nowhere, which is the ADR-0049 + shape. + + ## What changed + + Both doors onto that shape, in one change: + + 1. **`SortNodeSchema`** (`spec/src/data/query.zod.ts`) is now `strictObject` + with `aliases: { direction: 'order' }`. An unknown key is rejected, and + `direction` specifically gets the translation in the error message — edit + distance can never bridge `direction` → `order`, so a bare "unrecognized key" + would leave the caller exactly where the silent strip did. + 2. **`normalizeSortNodes`** (`metadata-protocol/src/protocol.ts`), the ingress + every REST/RPC `orderBy` funnels through, refuses `{ field, direction }` with + `400 INVALID_SORT` naming `order` and quoting the corrected node. Closing only + the schema would repeat the door asymmetry of #1535/#4522: `SortNodeSchema` is + reachable by three paths the REST normalizer never sees. + + | `orderBy` you send | Before | After | + | :----------------------------------------------------- | :-------------------------- | :---------------------------------------------------------- | + | `[{ field: 'x', order: 'desc' }]` | descending | unchanged — descending | + | `[{ field: 'x', direction: 'desc' }]` | **200, ascending** | `400 INVALID_SORT`, message names `order` | + | `[{ field: 'x', order: 'desc', direction: 'asc' }]` | 200, descending | `400 INVALID_SORT` | + | `'-x'` / `['-x']` / `{ x: 'desc' }` | descending | unchanged | + | `{ direction: 'desc' }` (the `{field: direction}` map) | sorts by column `direction` | unchanged — a column may legitimately be called `direction` | + + Scope is deliberately narrow: **`QuerySchema`'s top level is untouched** and + still accepts undeclared keys (`QuerySchema.safeParse({ object: 'sales', +nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map + for its own batch, not smuggled in here. + + Related: #4674, #4720, #4363, #4371, #4001, ADR-0049. + +### Patch Changes + +- 29c6c9d: feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) + + The declarative API-endpoint surface was **zero-execution end to end**, and said nothing + about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack` + accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact. + The execution side never fired once. On a real boot (showcase, 47 plugins) both declared + paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic + 404, because **no route was ever mounted** for a declared path, so the request died at + Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the + metadata service and called `matchEndpoint` on it — a method **no implementation in the + repo has ever provided**. The branch returned "not handled" on every request ever served. + + So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never + mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`, + `inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and + **`authRequired`**, a security semantic that parsed green and gated nothing at all. That + is false compliance, the failure ADR-0049 exists to stop, not debt. + + ## BREAKING — a non-empty `apis:` is now rejected + + Metadata that parsed cleanly before is now **refused at publish/validate**, with the + prescription in the rejection itself: + + ``` + apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, + so a non-empty array is rejected instead of silently accepted (#4936). … + ``` + + **FROM → TO.** `apis: [ …endpoints… ]` → `apis: []` (or delete the key; both are still + accepted, and an empty array is not a special case). To actually serve the route today, + mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server` + route. That is now the only honest path, and the one `examples/app-showcase` uses + (`src/system/server/recalc-endpoint.ts`). + + The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke + point every path runs through — `defineStack`, the metadata plugin's artifact ingestion, + `os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that + forgot to check. + + **The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and + rejected: endpoint shapes are an industry-stable form, so a retirement would only mean + re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript + and stay in the spec; only _authoring them into a stack_ is refused, and only until the + executor lands. Keep them commented next to your stack — that is what the showcase does. + The executor (route mounting + endpoint matching + per-key wiring for + `authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by + **#5040**, which replaces this rejection with real execution. + + ## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939) + + The repo carried a **second**, unrelated declaration shape for "an API endpoint": + `ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that + `createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every + assembly site lived in `packages/core/examples/`, with no registration in + `packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no + such service. The whole family was therefore inert, including + `ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense** + that "the gateway layer automatically validates these permissions" while no gateway read + it. Two declaration shapes, both dead; this retirement converges them on one. + + Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`, + `ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`, + `ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`, + `ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`, + `ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys). + Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`. + Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was + defaulted to `true` and read by nothing, configuring a service that was never composed. + + **FROM → TO.** There is no replacement shape to migrate to, because nothing executed the + old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`, + you were building a value only your own code read — keep it as your own type. Declarative + endpoints have one vocabulary now, `ApiEndpointSchema`. + + `ConflictResolutionStrategy` **survives** the removal and moved to + `@objectstack/spec/api`'s `router.zod` — same name, same four values + (`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two + independent ratchets and is not part of the retired surface. + + ## Also in this change + + - **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted, + along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves + `LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API + removal — but it returned `{ handled: false }` for every call it ever received, so no + caller can observe a behaviour change beyond the missing symbol. Delete the call. + Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing + at runtime with dead code behind it. + - `examples/app-showcase` no longer declares endpoints, and its coverage manifest no + longer claims the capability is `demonstrated` — that entry read "executed by the runtime + dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver + claim Prime Directive #10 forbids. + - The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**, + the live executor card, instead of #4936, which closes with this change. + +- e96ad55: fix(metadata-protocol): `batchData`'s upsert fork decides update-or-insert by EXISTENCE, not caller visibility (#5099) + + The fork asked `findOne` under the CALLER's context — the read RLS/sharing + narrows (#3455). An existing row outside the caller's read scope therefore + answered `null` and took the INSERT arm: on a store with a unique id constraint + the insert duplicate-keyed (an authorization/update scenario reported as a key + collision — the same misdirection class as #5088), and on a store without one + it wrote a **second row** for an id that already exists. + + The fork now uses the same existence probe (`probeRecord`, system context) as + the single-record path and the update/delete bulk faces (#4620: one reading per + file). Whether the caller may WRITE the row it proves stays exactly where it + was — #1994's pre-image check inside `engine.update` — so the row's outcome is + the write policy's own answer instead of a spurious `duplicate key` error. + + **Observable change under row-level visibility**: upserting an id that exists + outside your read scope no longer attempts an insert. The row now answers + whatever the by-id update path answers for that record (for a masked pre-image + check, the same 404 a direct update returns). The existence oracle is not + widened: the previous duplicate-key failure already revealed that the id + exists. + + The non-atomic fallback (update threw → blind insert) is removed with it, on + both arms. With existence decided before the fork, the fallback could only + bury a real update failure under the duplicate-key error of inserting a row + just proven to exist — the same masking ADR-0119 D4 already forbade inside the + atomic arm. A row whose update fails now reports that failure. + + Cost note: each by-id upsert row now performs one existence read before the + write — the same probe cost #4435 accepted for the single-record path and + #5088 accepted for the update/delete bulk faces. + +- 75bb3af: fix(metadata-protocol): the by-id BULK write faces refuse a row that names no record (#5088) + + `updateMany`, and `batch`'s `update` and `delete` branches, now answer + `RECORD_NOT_FOUND` (404) for a row whose id resolves to nothing — the same code + and the same message (`Record not found in `) the single-record + `PATCH` / `DELETE` have answered since #4435. + + Before this, #4435's "a write that touched zero rows must not report success" + was live on only 2 of the 5 write faces in `protocol.ts` (`updateData`'s + existence probe and `deleteMany`'s `deleted === false`). The three bulk faces + went straight to the engine, with two visible consequences: + + - **`updateMany` / `batch.update`** — a stale id entered the write pipeline. + With no stored row to overlay, #4770's record materialisation (stored ⊕ + payload) produced a payload-only record, a hook `condition` reading any + untouched field found it absent, and #4775's unevaluable-condition abort + fired. The row failed `INTERNAL_ERROR` with a diagnostic accusing a _correct_ + hook of naming an undeclared field, so an operator with one stale id in a + batch was told their hook was broken and pointed at the object's field list. + Under `atomic: true` that row also poisoned the batch, taking every later row + to `NOT_ATTEMPTED`. Hooks, automation and audit rows no longer fire at all for + a record that does not exist. + - **`batch.delete`** — discarded the driver's return and reported + `success: true` unconditionally, so a batch of typo'd ids reported every one + of them deleted. It now reads the driver contract's positive not-found value + (`=== false`), exactly as `deleteMany` does. + + Existence is asked with the same `probeRecord` the single-record path uses: it + answers EXISTENCE, not visibility, so the by-id write policy stays #1994's + decision inside `engine.update` and the `rls-by-id-write` proof can still go + red. `upsert` is deliberately unchanged (a missing id still inserts), as are + the predicate bulk writes (`multi: true`, no per-row id) and the `atomic` + response shape — the causal row keeps its position, later rows stay + `NOT_ATTEMPTED`, and rows with real ids behave exactly as before. + + Note for high-volume callers: each by-id row in these three faces now costs one + extra existence read before its write. + +- 1f82d1e: fix(metadata-protocol): `allowRuntimeCreate: false` is enforced on every kernel — `PUT /meta` no longer creates `job` / `agent` items the registry declares code-only (#5086) + + #4509 set `allowRuntimeCreate: false` on `job` and promised the refusal without + qualification — _no "create job" in Studio or via `PUT /meta`_. ADR-0063 §2 says + the same for `agent`. The gate that keeps that promise existed, and worked, but + it sat behind `environmentId !== undefined`: + + ```ts + if (this.environmentId !== undefined) { + // …not_overridable / not_creatable… + } + ``` + + `environmentId` is a **row-scoping key**, not an authorization signal. Every + kernel assembled without one ran with the entire ADR-0005 authorization gate + disengaged — and that is not an exotic topology. The CLI's lightweight + assembler builds exactly that for a host config (`isHostConfig` → the + `createStandaloneStack` branch is skipped → `new ObjectQLPlugin()` with no + `environmentId`), which is the flagship showcase and every self-hosted app + server shaped like it. On those, the issue's repro answered: + + ``` + PUT /api/v1/meta/job/rc3_runtime_job + {"name":"rc3_runtime_job","label":"J", + "schedule":{"type":"cron","expression":"0 0 * * *"},"handler":"nope"} + → 200 {"success":true,"message":"Saved customization overlay (env-wide) — type=job, …"} + ``` + + `handler: "nope"` names no function in any compiled bundle. The row persists, + lists, and can never be scheduled — the record #4509 exists to prevent, saved + and reported as success. It is the ADR-0049 failure mode one level up: the + _enforcement flag itself_ was the silently-inert declaration, and Studio (which + reads the flag to hide "create") honoured a rule the API underneath did not. + + **What changed.** A type whose registry entry sets BOTH `allowRuntimeCreate: +false` AND `allowOrgOverride: false` declares that it has no runtime write + channel at all. `saveMetaItem` now refuses it on every kernel, before + persistence, in draft mode as well as publish: + + | write | before | now | + | ------------------------------------------- | ------------- | --------------------- | + | `PUT /meta/job/*` on a single-kernel host | `200 success` | `403 NOT_CREATABLE` | + | `PUT /meta/agent/*` on a single-kernel host | `200 success` | `403 NOT_CREATABLE` | + | same, over a name a code package ships | `200 success` | `403 NOT_OVERRIDABLE` | + | project-scoped (cloud) kernels | `403` | `403` (unchanged) | + + The refusal names the type, the flags that produced the verdict, the source + file pattern to declare it in (read from the type's own registry entry, so a + newly-flagged type carries an accurate hint the day it is flagged) and the + `OS_METADATA_WRITABLE` escape hatch. + + **Scope, deliberately.** The rest of the ADR-0005 two-tier gate keeps its + single-kernel carve-out: that ADR's "single-kernel deployments keep their + existing behaviour" sentence is about the _overlay whitelist_, predates + `allowRuntimeCreate` entirely, and a type that stays runtime-creatable + (`object`, `hook`, `field`, `seed`, `mapping`, …) is untouched here. So is + `deleteMetaItem` — removing a code-only row that predates this refusal is + repair and must stay possible. `OS_METADATA_WRITABLE` remains the one door: + unlocking a type there unlocks it here too. + + **Upgrading.** If a deployment relies on runtime-created `job` or `agent` rows, + move them into source (`**/*.job.ts`, `**/*.agent.ts`) and redeploy — a `job` + authored at runtime never had a reachable `handler` in the first place. To keep + writing them while migrating, set `OS_METADATA_WRITABLE=job,agent`. + +- da538b1: seed-loader: a pass-2 back-fill dropped for a missing source-record id is now reported, not silently discarded + + `resolveDeferredUpdates()` looked the source record's internal id up in `insertedRecords` + and, when it was not there, ran off the end of an `if` with no `else`. Pass 2 had already + RESOLVED the target, and the back-fill then evaporated: no write, no entry in + `errors`/`allErrors` (so the load still reported `success: true`), no `errored`, and not + one log line. The only trace was the `referencesDeferred` the record booked in pass 1 and + never gave back — a dangling number with nothing in the result explaining it, while the + declared association stayed absent forever. + + It now records the loss through `recordDeferredError` (→ `errors`/`allErrors` + `errored`, + so the load reports `success: false`) and logs it once at `error`, per the same objective + criterion applied in #4729/#4997 and the "Degradation log levels" rule. The two ways to + get here are worded differently because they are different failures: an EMPTY + `recordExternalId` — `externalIdKey` returns `''` when any component of a composite + externalId is blank — is the pure silent loss, where the row wrote perfectly, nothing else + in the load reports anything and the reference stays NULL forever; a real key that is + simply absent from the map means the source row never landed, and that write failure was + already reported at `error`, so this line points at it instead of restating it. + + A load that hits this path previously returned `success: true` with clean counters and now + returns `success: false` with the loss counted — the seed data was always incomplete; it + just was not saying so. + +- 79822b5: fix(metadata-protocol): stop `promoteDraft`'s draft drain from swallowing every failure (#4981) + + Publishing a draft is two writes: a transactional `put` that promotes the body onto + the active row, then a `delete` that drains the now-redundant `state='draft'` row. + The drain was guarded by a bare `catch {}` whose comment named exactly one cause — + "a concurrent publisher may have already drained the draft" — while its behaviour + covered **all** of them: connection drops, statement timeouts, missing privileges, + driver faults, `parentVersion` mismatches. + + The result was a silent, self-perpetuating inconsistency. `publishDraft` returned + success, the active row was correct and durable, and a stale `state='draft'` row + stayed in `sys_metadata` holding the body that had just been published. Nothing + logged it and nothing retried it, so Studio/Setup kept reporting "unpublished + changes" for an artifact that had none, and the next publish of that artifact + promoted the same already-published body again — which overwrites the active row if + anything published or reverted in between. + + **The drain now discriminates by cause.** `ConflictError` — the only error + `delete()` raises from its own pre-driver row lookup — stays silent, because both of + its arms are genuinely benign: `actualHead === null` is the concurrent-publisher + race the old comment described, and a differing head means a _newer_ draft was saved + while the publish was in flight, so the surviving row is real pending work that must + not be dropped. Every other failure is reported at `error` level (per the + `warn`-vs-`error` rule: the system keeps looking healthy while something it claims to + have cleaned up is still there), naming the orphaned artifact, the consequence, and + the remedy, with the original cause attached. + + **`promoteDraft` still returns success, deliberately.** The drain runs _after_ the + `put` has committed, so throwing would misreport a durably successful publish as a + failure and invite the caller to retry — and a retried publish is precisely the + harmful path, because it re-promotes the stale draft. The failure is surfaced + without lying about the publish instead: alongside the log, the result carries a new + optional `draftDrainFailed` field (`{ ref, draftHash, cause }`, exported as + `DraftDrainFailure`) so callers can react without parsing logs. It is an additive + optional field on an existing result object — absent on every clean publish — so no + existing caller changes. + + No protocol or spec shape changed. The drain seam is registered with + `pnpm check:durability-log-level` (as the named callee `dropPromotedDraftRow`) so + the catch cannot quietly go back to swallowing everything. + +- 15e61fb: fix(metadata-protocol): `publishPackageDrafts` 现在对 `api` draft 跑 ADR-0121 端点发布门 (#5206 step 2) + + `protocol.publishPackageDrafts` 是 Studio「全部发布」的真实入径(ADR-0033 / + ADR-0067 D2)。在此之前,它唯一的按类型前置检查是对象命名空间前缀 + (`validateObjectNamespacePrefix`,仅 `d.type === 'object'`),于是一条 `api` + draft **不经任何一道门**就被提升为 `active` —— 与 #5189 在 + `MetadataManager.publishPackage` 上修掉的是同一形状、另一条路。 + + 安全后果早已被 PR #5203 的装载期兜底挡住:端点匹配器在建索引时用同一个 + `firstFailure` 重判每一条存量条目,没过门的被排除出索引并 `error` 点名。所以 + 这次修的是**拒绝得太晚**:ADR-0121 的原文是「publish 拒绝」,作者应当在 + publish 当场拿到点名 key 的处方,而不是到装载期日志里才发现自己的端点在答 + 404。 + + **判据只有一份。** 本改动调用 `@objectstack/spec/api` 导出的 + `validateApiEndpointDeclarations`(#5203 公开)—— 就是 stack schema 跑的那个 + 函数、`publishPackage` 跑的那个函数、装载期兜底跑的那个 `firstFailure`。拒绝 + 文案直接用门函数自己的消息(已包含端点名、越界的 key 和改法),本包不复述任何 + 一条「什么算可服务」的规则。 + + 与 `publishPackage` 不同,这条路**有身份**:包的 `manifest.namespace` 本来就 + 为对象前缀规则读过了,所以这里跑的是**全量门**,命名空间门(ADR-0121 D1/D2) + 包含在内。命名空间门**不**以「包声明了 namespace」为条件 —— 门函数自己的前置 + 判据(声明了 `apis:` 的 stack 必须显式声明 `manifest.namespace`)本身就是一条 + 判据,对「压根没有 namespace」的包跳过它,等于给最不可能过编译期的那批包留一 + 个洞。对象前缀规则对无 namespace 的包网开一面,是因为一个裸对象名只是命名气味; + 一个无命名空间的端点是一个**无主 URL**。 + + **行为变化(用户可见)**: + + - 一条 `api` draft 若违反端点门(最典型:ADR-0121 D6 —— `authRequired: false` + 却没有 `rateLimit.enabled: true` 的预算),`publishPackageDrafts` 现在返回 + `success: false` / `publishedCount: 0`,该条目进入 `failed[]`,`code` + 为 `ENDPOINT_GATE`;body 连 `ApiEndpointSchema` 都不满足的,`code` 为 + `ENDPOINT_SCHEMA`(解析是判定的前置,不是第六道门 —— 判不了的形状也服务不 + 了)。 + - **失败粒度沿用既有语义,未发明新的批次语义**:与命名空间前缀违规完全一致, + 这是一次**提升任何东西之前**的前置拒绝,整批不落地(`published: []`),同批 + 的健康 draft 保持 draft 态。这既是 ADR-0067 D2 的「一次 commit 不能落一半」, + 也是 #5189 在另一条路上的同一姿势(`itemsPublished: 0`)。两类违规现在合并 + 在**同一份报告**里返回,作者一次往返就能看全。 + - 判定范围是**本批被提升的 draft**,与紧邻它的对象前缀规则一致。与同包已 + `active` 的端点撞车不在此拦截 —— 匹配器对全库重复声明有确定性裁决并 `error` + 点名(`buildEndpointIndex`);把范围扩到整包 active 集合意味着「因为你没在发 + 布的东西而拒绝这次发布」,那是另一份契约,不是一个 bug 修复。 + + 装载期兜底(#5203)原样保留,未移除也未削弱:publish 是**更早**的那道门,不是 + 最后那道门的替代品。 + + `api` 进 `DEFAULT_METADATA_TYPE_REGISTRY` / `BUILTIN_METADATA_TYPE_SCHEMAS` + (即 Studio 直写路径的 422)是 #5206 的第 1 步,拆在子单 #5271(spec 车道); + 本改动**不依赖**它落地。 + +- 1d29e6d: fix(metadata-protocol): a seed failure that is COUNTED as an error now logs at `error` (#4729) + + `SeedLoaderService`'s pass-2 deferred back-fill carried a comment stating that a + failed back-fill "must be a reported, counted error, **never** a silent warning" + — and the line under it called `logger.warn`. The count was right (the failure + lands in `result.errors`, flips `success: false`) but the level contradicted it, + and that log line is the only trace a seed leaves in a host's console. `warn` is + the level #4420 proved nobody reads. + + **What changed** + + - The failed back-fill logs at **`error`**, and the line now owes what + AGENTS.md → "Degradation log levels" requires of one: the **consequence** + (`.` stays NULL on a named record, the row itself was seeded so + every row counter reads clean, the circular relationship is half-written) and + the **fix** (nothing retries it — repair the write error, which is either a + transient failure that outlasted the retry budget or a validation rule vetoing + the update, then re-run the seed). + - The rest of the file was audited against the same criterion — _is this failure + counted in the load's `errors` (i.e. does it make `success: false`)?_ Five more + sites answered yes while logging `warn`, and were raised to `error`: a failed + batch insert row, a record dropped because its `cel` expression could not + resolve, the two invalid-reference paths that DROP a reference field (the row + lands without its association and the row counters stay clean — framework#3932), + and the two write-failure catches on the sequential/update paths. The two + dropped-reference lines also gained the consequence and fix in the message. + - Deliberately left at `warn`, and now documented as audited: "Halting on first + error" (a control-flow notice about failures already reported at `error`), the + `NODE_ENV` scope warning (a functional, fail-open degradation), and the + roll-up-summary recompute (records _were_ written; whether a stale summary + column is the same class is #4998). + - The seam is now pinned by CI, not only by tests: the back-fill write was + extracted as `writeDeferredReference` and added — with `writeRecord` — to + `DURABILITY_CRITICAL_CALLEES` in `scripts/check-durability-degradation-log-level.mjs`, + so `pnpm check:durability-log-level` fails if either catch is ever quietened + again. + + No API, schema or result-object change: the same errors are reported in + `SeedLoaderResult` exactly as before. What changed is the level and the wording + of what a seeding host sees in its log. + +- c5a5996: fix(seed-loader): a roll-up summary left stale by a seed is now loud and counted + + The loader recovers a post-write roll-up summary recompute that exhausts its + retries (`ERR_SUMMARY_RECOMPUTE`), and that recovery is correct: the rows WERE + written, so re-writing them would duplicate them (framework#3147). What was + wrong was the rank of the consequence. A roll-up summary is a **persisted + derived column** on the parent record, so after this the database is internally + inconsistent — the detail rows say one thing and the column that summarizes them + says another — and nothing recomputes it until some later write happens to touch + the same parent, which after a seed may never happen. + + The entire event used to be one `warn` line reading _"records were written + (summary values may be stale)"_. It named no object, counted nothing, and left + `success: true` with every row counter clean, so no operator could see which + aggregate was wrong and no caller could detect it at all + ([#4998](https://github.com/objectstack-ai/objectstack/issues/4998)). + + **It now logs at `error`**, naming the seeded object and the exact stale column + (`account.total_billed`), stating the consequence (the summary and its detail + rows disagree, nothing self-heals, and the seed still reports success) and the + remedy (fix the recompute error and re-run the seed, or trigger any write on the + affected parent to force a recompute), with the original cause attached. This is + the AGENTS.md "Degradation log levels" rule (#4632): persisted state and runtime + state disagreeing while everything looks normal is `error`, not `warn`. + + **And it is counted** — `SeedLoadResult.summariesStale` and + `SeedLoaderResult.summary.totalSummariesStale`, mirroring `referencesDropped` / + `totalReferencesDropped`, which exists for the same shape one layer down ("the + row was written, something derived from it was lost"). A log line is not + something a caller can branch on; these counters are. + + `success` deliberately stays `true`. It answers _"did the rows land"_, and they + did — every consumer treats `success: false` as "the write failed", so flipping + it would hand the protocol seed-apply surface a `false` with an **empty** errors + array and fail package/marketplace installs that in fact wrote every row. The + counter carries the signal instead; a caller that wants to treat a stale + aggregate as fatal reads `summary.totalSummariesStale > 0`. + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected — a payload written before this + release still parses, with `0`. + +- 5ea8e1e: fix(metadata-protocol): a seed record dropped for an unresolvable reference now says so at `error` (#4997) + + When a seed's `lookup` / `master_detail` / `user` reference could not be + resolved and no pass 2 would run (`multiPass: false`), the loader dropped the + **whole record** — the right call, since writing it would put the raw + natural-key string into the FK column or, on an upsert UPDATE, corrupt the row + already there. The drop was counted (`errored`) and reported + (`result.errors` → `success: false`), and the code comment above it claimed + "LOUD", but the branch made **no logger call at all**. On the console a seed + that silently dropped N records was indistinguishable from a clean one, and the + `packages/runtime` seed call sites that only `await` the load never look at + `result.success` — so the loss surfaced later as "the app installed but the data + isn't there". + + That branch now logs at `error`, per AGENTS.md → "Degradation log levels" + (#4632): the line names the record (`` record #i), the field, the target + `.` it could not find, and the **consequence** (the whole record + was not seeded — not merely the association), followed by all three **remedies** + — seed the target object first, enable `multiPass` so pass 2 back-fills the + reference, or fix the natural key in the seed data. + + The same objective criterion (does the outcome enter `errors`/`allErrors`?) + found one more never-logged branch in the same file and aligned it: a **deferred + reference still unresolved after pass 2** was counted exactly like its sibling + whose back-fill _write_ fails — which has logged at `error` since #4729 — and + logged nowhere. It now reports that the row was seeded while the relationship is + permanently missing, and how to complete it. + + The **dry-run** branch stays deliberately quiet and is pinned that way by test: + a dry run writes nothing, its caller is by definition reading the result object, + and an `error` line about a simulated outcome only trains readers to skim + `error`. No counters, result shapes or messages in `result.errors` changed — + this is console output that was missing, not a contract change. + +- dca25e1: fix(metadata-protocol): `SysMetadataRepository` 的 `event_seq` / `version` 不再从一次失败的读里凭空发号 —— 只有「表还没建」可以从 1 开始 (#4867) + + `SysMetadataRepository.nextEventSeq()` 与 `nextItemVersion()` 各有一个同形的 `catch`,把读 + `sys_metadata_history` 的**全部**失败折成同一个答案: + + ```ts + } catch { + // Table not provisioned yet (fresh DB) — start at 1. + return 1; + } + ``` + + 这是 #4825 刚在 `DatabaseLoader`(TSDoc 自称 legacy、非事务的那条路径)上修掉的形状,原样长在 + **canonical 路径**上 —— #4825 正文把 `SysMetadataRepository` 称作「历史写入应当收敛过去的地方」。 + 而且这里有两个数字: + + - **`event_seq`** —— 历史排序与 rollback 定位的依据。表里已有 N 行时,一次瞬时读失败(连接抖动、 + 超时、权限)让下一条拿到 `1`,与既有行撞号; + - **`version`** —— `nextItemVersion()` 的 TSDoc 明说它刻意从 history 取 MAX「so delete + recreate + continues incrementing instead of restarting at 1」。一次读失败正好把它**恢复成它明确要避免的那个 + 行为**:lineage 从 1 重启并与既有 lineage 撞号,而 `MetadataManager.rollback(type, name, version)` + 与 `POST /api/v1/meta/:type/:name/rollback` 正是按这个数字定位快照 —— 撞号之后回滚可能落到另一条 + 记录的同号版本上。 + + 关键危害与 #4825 相同,是「**落盘的字节是错的**」而不是「字节没落盘」:insert 成功、日志一行没有、 + 系统对外完全正常,重试不修、重启也不修。 + + **「在事务里」并不能挡住它。** 事务解决的是*并发*撞号;它对「从一次失败的读推导出来的数字」没有任何 + 意见,一个成功提交的事务照样把错号提交得同样持久。事务真正给出的是干净的补救:抛出去,整笔写入回滚, + 而不是提交一个编造的号。 + + 现在按**错误类型**判别,复用 #4825 落地的那套判别器(不另起一套): + + - **良性的「表还没建」** —— 没有行,就没有可撞的号,`1` 确实是下一个号,静默返回,fresh DB 照常启动; + - **其余一切读失败** —— 按 AGENTS.md「Degradation log levels」以 `error` 上报**后果**(写入已被中止、 + 事务回滚、什么都没提交;若按旧行为发 `1` 会与既有行撞号,使版本顺序不可信、回滚目标可能指向另一条 + 记录的同号版本,且无人能发现、重启也修不回来)与**修复动作**(修数据源/驱动错误后重试写入),然后 + **原样抛出**,让事务回滚。一次故障只说一次,恢复时补一条 `info`。 + + ### `@objectstack/metadata` 新增子路径导出 `@objectstack/metadata/errors` + + 判别器 `isMissingTableError()`(#4728/#4825 家族)此前是 `@objectstack/metadata` 的内部工具,而本次 + 消费者在另一个包。三个选项中选了「从现有归属地**显式导出**」:在 `metadata-protocol` 里复制一份会重建 + #4825 刚消灭的双源问题(同一个问题两套「哪些驱动错误算良性」的词汇表,谁先学会一个驱动怪癖谁就先漂移); + 下沉到公共依赖本轮不可行(`packages/spec` 冻结、`packages/types` 有并行改动),且本次导出并不妨碍维护者 + 之后再下沉。 + + 新增的是一个**叶子子路径**而不是包入口导出:`@objectstack/metadata` 的根入口会拖进 manager、全部 + loader 与其 YAML/文件系统依赖,只为一个 40 行谓词付这个重量,正是把下一个作者推回「复制一份」的原因。 + `@objectstack/metadata/errors` 只 re-export 一个叶子模块,跨包依赖边因此仍是叶子边,也是将来下沉时 + 一个可 grep、可删除的单点。仅导出 `isMissingTableError`;同族的 `isSchemaAlreadyExistsError` 在包外 + 没有消费者,保持内部(导出一个无人 import 的符号是白许的承诺)。 + + 无 API 破坏、无 schema 变更、无 `packages/spec` 改动。 + +- 38f53a0: fix(metadata-protocol): `updateMany` classifies an id-less row as a caller error, matching `batchData`'s update branch (#5100) + + `runUpdateManyLoop` lacked the `!record.id` guard #4793 gave `runBatchDataLoop`'s + update branch, so the two by-id update faces classified the same malformed row + differently: `VALIDATION_FAILED`/400 on batch, but on `updateMany` the row fell + through to the #5088 existence probe as `{ id: undefined }` and came back + `RECORD_NOT_FOUND`/404 with `undefined` interpolated into the message — a + request-shape error reported as a data-state one, with the row's fate left to + each driver's undefined-where-key handling. + + Not reachable over REST (`UpdateManyRecordSchema` requires `id`, #3939) — the + change is observable only to in-process callers of the protocol method, whose + id-less rows now answer `VALIDATION_FAILED`/400 (`Record id is required for +update`) before any engine round-trip, identically on both faces (#4620: one + classification per file, enforced by a cross-face parity test). `record.data` + handling is aligned to the batch branch's `record.data || {}` in the same + change. + +- Updated dependencies [c1e67e0] +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [533a0a4] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [3133cda] +- Updated dependencies [c142ced] +- Updated dependencies [c794f78] +- Updated dependencies [641363a] +- Updated dependencies [ecc61ab] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [06ffad3] +- Updated dependencies [729a43a] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [5d3ced9] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [461ccda] +- Updated dependencies [f238970] +- Updated dependencies [95b4f0d] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [2f1e2a5] +- Updated dependencies [108ba8d] +- Updated dependencies [b821b29] +- Updated dependencies [af96af6] +- Updated dependencies [5b8f95b] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [da1a64c] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [ddd6650] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [dca25e1] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [e92e2c3] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/lint@17.0.0-rc.3 + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/metadata@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 65046143b9..2c3d05b72c 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.2", + "version": "17.0.0-rc.3", "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 f018f17d0d..9dbb91dfc9 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,535 @@ # @objectstack/metadata +## 17.0.0-rc.3 + +### Minor Changes + +- ecc61ab: feat(metadata): 端点匹配器 —— `MetadataManager.matchEndpoint` 惰性索引实现 (#5089) + + `IMetadataService.matchEndpoint?` 的契约在 #5080/#5097 落地(声明先行),本变更补上 + `metadata` 槽位占位者 `MetadataManager` 的实现:把已声明的 `api` 元数据条目编成 + **METHOD → 精确路径 → 端点** 的惰性索引,供 HTTP 分发器在「没有内建域认领这条路径」 + 与「回答语义 404」之间做一次查表。这是 #5040 端点执行器程序的 E2 单。 + + **结构性不可达,零行为变更。** 17.x 里没有任何东西会调用 `matchEndpoint`:挂载 seam + 是 #5090 的面,而 publish/validate 对非空 `apis:` 仍然硬拒(#4936)。新代码在真实组合 + 里不暴露任何 HTTP 行为;测试直接驱动服务,这正是 #5040 设计选定的验收姿态。 + + 实现要点(逐字实现契约文本,`packages/spec/src/contracts/metadata-service.ts`): + + - **匹配维度**:`method` 大写规整后比较(请求动词大小写不敏感);`path` 去掉**一个** + 尾斜杠后**整串精确**比较,两侧同规则。17.x 不做百分号解码、不做 Unicode 规整、 + 不做大小写折叠 —— 原串即键。词表(ADR-0121)未定义任何路径模板语法,因此 + `params` **恒为 `{}`**;此处不发明只存在于实现里的方言。 + - **答案是 parse 后的形状**:每条经 `ApiEndpointSchema.safeParse`,默认值已物化 —— + 作者省略 `authRequired` 时消费方拿到的是 `true`,不可能把「缺省」误读为放行。 + - **坏条目响亮缺席**:解析失败的存量条目被跳过并以 `error` 级点名(说明该路由将回 404 + 及如何修),绝不返回半合法形状,也绝不牵连同批的好条目。 + - **重复声明确定性收敛**:两条条目声明同一 METHOD+path 时,`name` 字典序在前者保留 + 路由,被弃者连同规则一并 `error` 级点名 —— 不是静默 last-write-wins,每个节点、每次 + 启动的解析结果一致。 + - **断存储抛错,不伪装 404**:`undefined` 只表示「无声明拥有这条路由」;读不到存储时 + 抛出(与 `loadDiagnosed` 的 miss/outage 之分同源,ADR-0110 D3),因为 miss 会变成 + 404,而故障不得伪装成 404。构建失败不缓存,下次调用重试。 + - **失效**:挂在仓内既有机制上,不新造事件系统 —— `invalidateListCache('api')` 覆盖 + 全部本地写入(含 artifact 装载 / HMR 的 `{ notify: false }` 写入,这些按构造不经过 + watcher),`subscribe('api', …)` 覆盖集群对端回放(它只经 `notifyWatchersLocal`)。 + 失效后下次调用整体重建。 + + `ApiEndpointSchema` 与 `packages/spec` 未做任何改动(词表冻结)。 + +- c52e608: fix(metadata,spec): the endpoint publish gates now guard the metadata write path too (#5189, #5040 E7b) + + #5111 (E7) hung the five per-endpoint `apis:` gates on + `ObjectStackDefinitionSchema`, which every path that parses a **stack** runs + through — `defineStack`, `os validate`, the lint scorer, artifact ingest, + `EnvironmentArtifactSchema.metadata`. #5189 proved a stored `api` item need + never have been part of a stack: `MetadataManager.publishPackage`, a direct + `metadata.register()` and a Studio metadata write each mint one item at a time + and saw no gate at all. + + Three of the five gates degrade safely when bypassed — the executor answers a + structured 501 naming the item, and a path outside the `apps//` + carve-out simply matches nothing. **ADR-0121 D6 has no runtime counterpart**: + the runtime honours `authRequired: false` faithfully and `deriveBucketConfig` + returns `null` for a budget whose `enabled` is not `true`, so the bypass minted + an anonymous, zero-quota execution entry point — the exact shape D6 exists to + forbid. + + Two doors now, both running the SAME gate function rather than a second copy of + the criteria: + + - **Publish** — `MetadataManager.publishPackage` runs + `validateApiEndpointDeclarations` over the package's `api` items and fails + the publish, naming each endpoint and the key to fix, on the same + `validationErrors` surface it already uses. This pass is **not** governed by + `options.validate`: an opt-out on a security gate is the bypass this fixed. + - **Load** — the endpoint matcher's index build re-applies the _identity-free_ + subset (supported subset, mapping, policy/D6) to every stored item. A + declaration that never passed publish is EXCLUDED from the index and named at + `error` level, so a bypassed endpoint answers 404 with a loud log instead of + answering anonymously and unmetered. The namespace and uniqueness gates are + deliberately not applied there — both need a stack identity a stored row does + not carry. + + **New in `@objectstack/spec/api`** (the module was package-internal in #5111, + whose only consumer was one file away): + `validateApiEndpointDeclarations`, `identityFreeEndpointGateFailure`, + `EndpointGateIssue`, `EndpointGateIdentity`. + + **New option — `publishPackage(id, { namespace })`.** `MetadataManager` indexes + items by `packageId` and carries no manifest, so it cannot prove a namespace on + its own and will **not** infer one from the items it is judging (an + author-supplied value would make the ADR-0121 D1/D2 carve-out gate vacuous). + Callers that hold the package manifest pass its explicit `manifest.namespace`; + without it the namespace gate fails and the package's `api` items do not + publish — which is the rule, not a limitation: a publish that cannot prove a + namespace must not mint a URL under one. Packages that declare no `api` items + are untouched. + +### Patch Changes + +- d21c001: feat(spec)!: declarative `apis:` publishes again — the blanket refusal narrows to per-endpoint publish gates, and declared endpoints go LIVE (#5111, #5040 E7) + + ⚠️ **Read this as a security note, not a schema note.** Declarative endpoints + **execute** from protocol 17. Before this release the surface was inert end to + end — nothing mounted a declared `path`, no matcher existed, and every key + including `authRequired` parsed green and gated nothing — which is why #4936 + refused a non-empty `apis:` outright. The #5040 E-series built the executor + (mount seam, endpoint matcher, policy keys, execution targets, mapping keys, + OpenAPI enrichment), so the refusal's premise is gone and keeping it would be + the lie in the other direction. + + ## BREAKING — the refusal narrows, and what passes it is served + + `apis: [ …endpoints… ]` no longer fails wholesale. Each entry is now gated + individually, and **an endpoint that passes the gate is mounted and answers + real requests as soon as the stack is published.** + + **Before you upgrade, review every historical `apis:` block** — including any + you restored, generated from an older doc, or left in place because it was + known to do nothing. Pay particular attention to any entry that explicitly + declares **`authRequired: false`**: the schema default is `true`, so an + _omission_ is safe and needs no review, while an explicit `false` is the only + thing that opens **anonymous** access to that endpoint. ADR-0121 D6 now pairs + it with a mandatory armed rate limit — and "armed" means + `rateLimit: { enabled: true, … }`, because `enabled` defaults to `false`, so a + budget written without it meters nothing. + + ## The gates, each rejecting with its own prescription + + | gate | rejected shape | + | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **namespace** (ADR-0121 D1/D2) | a `path` that is not `/api/v1/apps//`, or a stack that declares `apis:` without an explicit `manifest.namespace` (no derivation from `manifest.id`) | + | **supported subset** | `type: 'script'` / `'proxy'`; an `object_operation` missing `objectParams.object` or `.operation`; a `flow` with an empty `target` | + | **mapping** | any `transform`; an unusable `source`/`target` path (empty, empty segment `a..b`, `__proto__`/`prototype`/`constructor`); two entries whose `target`s collide (same path, or one inside another); `inputMapping` on a `find`/`get`/`delete` operation, which never reads a body | + | **policy** | `authRequired: false` without `rateLimit.enabled === true`; an armed budget with `maxRequests`/`windowMs` ≤ 0; a negative `cacheTtl`; `cacheTtl` on a non-GET method | + | **uniqueness** | two endpoints in one stack claiming the same METHOD + path (one trailing slash trimmed, the matcher's own rule) | + + **FROM → TO.** `path: '/api/v1//thing'` → + `path: '/api/v1/apps//thing'`, with `manifest.namespace` + declared explicitly. `authRequired: false` → either delete the key (the safe + default `true` applies) or keep it **and** add + `rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }`. Every other + key is unchanged: the `ApiEndpoint` vocabulary is frozen — this release adds, + removes and renames nothing on it. The gates are validation logic over the keys + that already existed. + + The runtime keeps its own refusals for a declaration that reached the store + without passing publish (a direct `metadata.register()`), so the two ends agree: + what publish accepts is exactly what the executor serves. + + `normalizeEndpointPath` is now exported from `@objectstack/spec/api` and is the + one canonical form of a declared path — the publish gate and the endpoint + matcher (`@objectstack/metadata`) read the same rule instead of each carrying a + copy, so a stack can never publish a duplicate the matcher would silently + resolve to a single winner. + +- 533a0a4: fix(metadata): 集群对端的元数据写入现在会失效本节点的 `listCache` / registry (#5109) + + 多节点部署下,节点 A 改一条 `view` / `permission` / `flow`,节点 B 收到 + `metadata.changed` 广播后**只叫醒了 watcher,却没有失效自己的缓存**。 + `attachClusterPubSub()` 的订阅回调此前只做一件事 —— `notifyWatchersLocal()`, + 既不碰 `this.registry` 也不碰 `this.listCache`。后果是 B 上任何走 `list(type)` + 的读在 `LIST_CACHE_TTL_MS`(30 秒)窗口内继续返回改动前的清单;更糟的是,被叫醒的 + watcher(ObjectQL SchemaRegistry 桥、Studio HMR SSE)如果回头调 `list()` 重新拉取, + 拉到的还是旧的 —— 一份「失效通知」附带着失效数据。单机部署完全无感,只有多节点才暴露。 + + 这与该通道自己声明的用途相反(`ClusterMetadataChangedPayload`:"consumed by peers + to **invalidate their local caches**",另见 `content/docs/kernel/cluster.mdx` §6.2 + 与 `metadata-lifecycle.mdx`);现在实现与声明一致。 + + 修法沿用同文件里 `applyRepoEvent()` 自 ADR-0008 PR-6 起就用对的那条路径,并把两条 + 「外部写入」缝(仓库 watch 循环、集群对端回放)收敛到同一个私有方法 + `invalidateForForeignWrite(type, name)`: + + - **删除而不预填。** 即便事件带着 body,也只删除 registry 条目而不写入 —— + 那份 body 是别人那次写入的快照,可能已被后续写入取代,预填会与真实 head 竞态, + 并要求我们去规范化一份自己没有加载过的定义。删除后 `get()` 自然穿透到 loader / + repository,也就是真相所在。 + - **同步失效,先失效再通知。** 失效发生在收到消息的当拍(不在 `setImmediate` 内), + 通知仍然延迟派发。`setImmediate` 的存在理由是不让**消费方的 watcher 回调**背压 + pubsub 派发循环;而失效只是两次 `Map.delete`,不执行任何消费方代码,没有需要延迟的 + 东西——把它一起延迟只会留下「已收到广播、尚未失效」的读窗口,请求处理器里任何一个 + `await` 都足以撞进去。先失效后通知也与本文件其他写入路径 + (`register` / `unregister` / `applyRepoEvent`)一致,于是回头 `list()` 的 watcher + 拿到的是写后清单。 + - **无名事件只失效清单缓存。** `MetadataWatchEvent.name` 在 spec 里是可选的,无名事件 + 无法定位 registry 条目;此时不会把整个 type 的 registry 一并清掉 —— 那会驱逐 + `registerInMemory()` 注册的、任何 loader 都无法恢复的代码态构件(如 `origin:'code'` + 的 datasource)。 + + 回环抑制(`originNode`)仍然先于失效判断,本节点自己的广播不会让自己白白重建缓存。 + +- 3133cda: fix(metadata): `DatabaseLoader` 的读故障不再被吞成「什么都没声明」(#5108) + + `DatabaseLoader` 的五个读方法此前都把**任何**存储异常 `catch {}` 成各自的空值 —— + `load` → `null`、`loadMany` → `[]`、`exists` → `false`、`stat` → `null`、 + `list` → `[]`。于是 `sys_metadata` 所在库不可达时,`loadMany('permission')` 与 + 「这个环境一条 permission 都没声明」返回**完全一样的值**,而且异常是在 loader 内部 + 就被抹掉的:`MetadataManager` 那几个 `try/catch` 降级分支拿到的是一次「成功的空读」, + 根本不会触发,整条链上没有任何一处会说出「读失败了」。 + + 现在按**错误类型**判决(#4632 立的规矩,#4728 / #4825 已经在同一个文件里用过两次的 + 形状,判据复用现成的 `isMissingTableError`): + + - 唯一良性的失败原因是 `sys_metadata` 尚未 provisioned —— 那时确实没有行, + 「什么都没声明」就是事实,首次启动照旧返回空值、不报错、不缓存; + - 其余全部原因(连接断开、超时、权限不足、查询出错)意味着行还在、只是这次没读到, + 一律把驱动原始异常**原样抛出**,由调用方决定降级姿态。判据保守:无法正面识别为 + 「表不存在」的错误一律当作真故障。 + + 由此上层三个已有的机制第一次真的生效: + + - `MetadataManager.list()` 的降级分支会真的进,并且**升级到 `error`** + (AGENTS.md「Degradation log levels」:系统看着正常、它声称掌握的清单其实是残缺的), + 日志写明后果与修法,每次故障只说一次、恢复时再说一次;`list()` 仍然尽力返回可读 + loader 的内容 —— 这个 best-effort 姿态是刻意保留的。兄弟方法 + `MetadataManager.loadMany()` 的同一条缝走同一个判决,不让同一次故障在同一个文件里 + 报出两个级别; + - `MetadataManager.loadDiagnosed()`(ADR-0110 D3)对 `DatabaseLoader` 终于能报出 + `degraded` / `errors`,而不是把 outage 报成 miss; + - `listForIndex()` / `matchEndpoint`(#5089)契约要求「读不到存储必须抛出,不得伪装成 + miss(miss 会变成 404)」—— 这条此前对 `MemoryLoader` / `RemoteLoader` 有效、对 + `DatabaseLoader` 无效,现在对真实的 datasource loader 也成立了。 + + **行为变化**:`MetadataManager.exists()` 与 `listNames()` 本来就没有 `try/catch`, + 所以存储故障现在会从它们抛出,而不再静默答「不存在」/「空清单」。这正是本次修复要的 + 姿态 —— 可用性故障不是一次「没有」。 + +- c794f78: fix(metadata): a known-partial `list()` result is cached as degraded, on a 2s TTL instead of 30s (#5184) + + Since #5108 a loader that cannot read its store throws rather than answering + `[]`, so `MetadataManager.list()` catches, reports the outage once at `error`, + and keeps serving what the reachable loaders hold. That best-effort posture is + deliberate. What was not deliberate is what happened on the next line: the + known-short result went into `listCache` on the same 30s TTL as a complete read, + with nothing on the entry to say it was partial. + + The consequences were all invisible from outside. That one `error` line covered a + **30s window in which the failing loader was never asked again** — no retry, no + second signal, the manager simply re-served a set it already knew was short. When + the store came back, nothing noticed for up to another 30s, so #5108's recovery + line (`reportLoaderReadRecovered`) arrived that late too. And because the entry + carried no marker, no consumer of the cache — including that once-only report — + could tell a partial answer from a complete one. + + Not caching degraded reads at all was considered and rejected on evidence. The + `listCache` field comment records why the cache exists: security middleware + calling `list('permission')` from inside a user-initiated DB transaction, where + `DatabaseLoader`'s `engine.find('sys_metadata', …)` tries to take a second knex + connection while the transaction holds SQLite's only one, and knex waits out + `acquireConnectionTimeout` (60s). That hazard was re-verified against the current + driver stack and is still live — `DatabaseLoader._find()` still does not thread + the caller's transaction, `driver-sql` still models SQLite as a + single-connection pool (`activeTransactions`, `assertBareKnexSafe`, the latter a + dev/test guard that no-ops in production), and `plugin-audit` still threads the + transaction by hand for the same reason. Skipping the cache would have traded one + 30s silent window for a fresh 60s stall per call. + + So the entry is still cached, but as what it is: + + - `listCache` entries carry a `degraded` flag, set when at least one loader threw + while the result was being assembled. It lives on the entry rather than in a + side table, so every reader can distinguish a complete answer from a partial + one; entries are read through a single `readCachedList()` helper that applies + the flag and its TTL in one place. + - A degraded entry expires after **2s** (`DEGRADED_LIST_CACHE_TTL_MS`) instead of + 30s. The burst of repeated lookups inside one transaction is still absorbed — + those are milliseconds apart — while the window in which a known-short set is + served without re-asking anyone shrinks 15×, and recovery is noticed (and + logged) within seconds of the store healing. + - A complete read is unchanged: cached, not degraded, 30s TTL. + - The outage message now names the degraded TTL as the retry interval, since it + previously promised the 30s one. + + Also closes a `declared ≠ enforced` defect in the same field's comment: it claimed + the cache kept "only positive (non-empty) hits or repeated hits with a stable miss + signature". No such condition ever existed in `cacheListResult()`. The comment now + describes the policy the code actually implements, and the behaviour it claims + (an empty complete read _is_ cached) is pinned by a test. + + Internal caching policy only — no change to the `IMetadataService` contract or to + any public export. + +- 729a43a: fix(metadata): 文件系统改动同样失效本节点的 `listCache`/`registry`,不再只叫醒 watcher (#5218) + + `NodeMetadataManager.handleFileEvent()` 在 chokidar 报告 `add` / `change` / + `unlink` 之后只做两件事:重新 `load()` 一次文件内容,然后 `notifyWatchers()`。 + 它既不碰 `listCache` 也不碰 `registry` —— 而 `load()` 是纯读路径(它委托给 + `loadDiagnosed`,后者只遍历 loader),两个缓存都不写。 + + 后果是**同一个 manager 的两个读接口互相矛盾**。手改 `rootDir` 下的 + `view/.json` 之后: + + - `get(type, name)` 是新的 —— 它穿透到 `FilesystemLoader`; + - `list(type)` 在 `LIST_CACHE_TTL_MS`(30 秒)窗口内继续返回改动前的清单 —— + REST `/api/v1/metadata/:type`、Studio 左栏、`listViews()` 等一切走 `list()` + 的读都受影响。 + + 更糟的是被这次事件叫醒的消费者(Studio HMR/SSE 流、ObjectQL SchemaRegistry + 桥)正是通过回头拉 `list()` 来响应的,于是这次唤醒**递回了它自己刚刚宣告已失效 + 的那份数据**。 + + 这与 #5109(集群对端写入不失效本节点缓存)是同一形状、不同触发源,因此复用该 + 修复落地的 `invalidateForForeignWrite(type, name)`(可见性由 `private` 放宽为 + `protected`):文件改动正是「不是经由本 manager 写接口发生的写入」,没有任何东西 + 替它刷新过缓存,delete-而非-预填 的语义也正好对上 —— 穿透回 loader 读到的就是 + 文件的真相。 + + 两点与基类其余写路径一致的约束: + + - **先失效,再通知**(`register` / `unregister` / `applyRepoEvent` / 集群订阅者 + 都是这个次序),使 watcher 不可能同时观察到事件与事件前的缓存; + - **registry 条目一并删除**,不只是列表缓存。FS 加载的条目本来就不进 registry, + 通常无可删;但当同名条目此前被 `register()` / `registerInMemory()` 写过时, + 它在 `get()` 和 `list()` 中都会**遮蔽** loader,只删列表缓存会让那份陈旧副本 + 一直应答下去。 + + 命中面主要是开发期:`MetadataPlugin` 默认 `watch: true`,在 + `bootstrap: 'artifact-only'` 下被强制关闭,`standalone-stack` 显式传 + `watch: false`。因此 artifact 模式的 `os dev` 与 standalone 不受影响,非 artifact + 的默认 `MetadataPlugin` 装配受影响。 + + `type === 'api'` 的行为不变:端点索引此前已由 #5089 装的 `subscribe('api', …)` + 那条缝覆盖,本次改动把 `invalidateListCache` 那条缝也接上,两条缝对称。 + `EndpointMatcher.invalidate()` 是两次赋 `undefined`,重复失效幂等。 + +- 95b4f0d: fix(metadata): `list()` reads are single-flight, so the "one loader hit per TTL window" promise finally holds for concurrent callers too (#5253) + + `MetadataManager.list()` was a bare "read the cache → walk the loaders → write + the cache" sequence. The cache is written only once a read has **finished**, so + it absorbed the caller that arrived second in _time_ but never the caller that + arrived second in _flight_: every `list(type)` issued while the first read was + still walking the loaders missed, and each one walked every loader itself. The + `listCache` field comment states the guarantee the cache exists to provide — + "the loader is only hit once per TTL window" — and that guarantee held for + sequential callers only. + + That is not a rounding error on the path the cache was built for. The comment + names it: security/permission middleware calling `list('permission')` on the + request path while `DatabaseLoader`'s read sits inside a transaction that holds + SQLite's only connection, waiting out knex's `acquireConnectionTimeout` (60s). + Every concurrent request arriving during those 60s used to burn its own 60s, + because nothing had been written to the cache yet. The everyday version is + milder but constant: cold start, and the small burst of concurrent `list()` + calls that follows every invalidation point — `register()` / `unregister()`, a + cluster peer's write (#5109), a filesystem change (#5218) — each repeated the + full loader walk. + + Reads of one metadata type are now single-flight. A `list(type)` that finds a + read already running for that type joins it instead of starting a second + identical walk. + + - **Sharers share the outcome — as an explicit contract, not an accident.** + Every caller joining an in-flight read receives that read's exact result, + including when a loader was unreadable and the answer is known-partial. + `list()` is the best-effort listing seam and does not throw (the strict + counterparts remain `listForIndex()` and `loadDiagnosed()`), so a lost loader + is not an error to fail over from — it is the answer, and re-running the read + privately for a joiner would walk the same loaders against the same outage in + the same window. + - **#5184's degraded judgment is unchanged and is not bypassed.** A shared read + that lost a loader is still memoized `degraded: true` on the 2s TTL, never + laundered onto the 30s healthy TTL by having been shared, and every sharer + received that same partial set. + - **A write landing mid-read wins.** `invalidateListCache()` now retracts the + in-flight read as well as the finished entry. The retracted read keeps running + for the callers already waiting on it — they asked before the write — but it + loses the right to memoize its pre-write answer, so that answer cannot outlive + the write it predates; and a caller arriving after the write starts a fresh + read rather than joining a pre-write one. That second half is #5219 / #5229's + ordering bar restated for concurrency: a consumer woken by a metadata change + must not observe the event and pre-event state together. + - The in-flight map is self-cleaning — an entry is dropped when its read + settles, by that read only, so a fresh read that replaced it keeps its slot. + + Internal caching policy only — no change to the `IMetadataService` contract or to + any public export. Sequential callers behave exactly as before. + +- dca25e1: fix(metadata-protocol): `SysMetadataRepository` 的 `event_seq` / `version` 不再从一次失败的读里凭空发号 —— 只有「表还没建」可以从 1 开始 (#4867) + + `SysMetadataRepository.nextEventSeq()` 与 `nextItemVersion()` 各有一个同形的 `catch`,把读 + `sys_metadata_history` 的**全部**失败折成同一个答案: + + ```ts + } catch { + // Table not provisioned yet (fresh DB) — start at 1. + return 1; + } + ``` + + 这是 #4825 刚在 `DatabaseLoader`(TSDoc 自称 legacy、非事务的那条路径)上修掉的形状,原样长在 + **canonical 路径**上 —— #4825 正文把 `SysMetadataRepository` 称作「历史写入应当收敛过去的地方」。 + 而且这里有两个数字: + + - **`event_seq`** —— 历史排序与 rollback 定位的依据。表里已有 N 行时,一次瞬时读失败(连接抖动、 + 超时、权限)让下一条拿到 `1`,与既有行撞号; + - **`version`** —— `nextItemVersion()` 的 TSDoc 明说它刻意从 history 取 MAX「so delete + recreate + continues incrementing instead of restarting at 1」。一次读失败正好把它**恢复成它明确要避免的那个 + 行为**:lineage 从 1 重启并与既有 lineage 撞号,而 `MetadataManager.rollback(type, name, version)` + 与 `POST /api/v1/meta/:type/:name/rollback` 正是按这个数字定位快照 —— 撞号之后回滚可能落到另一条 + 记录的同号版本上。 + + 关键危害与 #4825 相同,是「**落盘的字节是错的**」而不是「字节没落盘」:insert 成功、日志一行没有、 + 系统对外完全正常,重试不修、重启也不修。 + + **「在事务里」并不能挡住它。** 事务解决的是*并发*撞号;它对「从一次失败的读推导出来的数字」没有任何 + 意见,一个成功提交的事务照样把错号提交得同样持久。事务真正给出的是干净的补救:抛出去,整笔写入回滚, + 而不是提交一个编造的号。 + + 现在按**错误类型**判别,复用 #4825 落地的那套判别器(不另起一套): + + - **良性的「表还没建」** —— 没有行,就没有可撞的号,`1` 确实是下一个号,静默返回,fresh DB 照常启动; + - **其余一切读失败** —— 按 AGENTS.md「Degradation log levels」以 `error` 上报**后果**(写入已被中止、 + 事务回滚、什么都没提交;若按旧行为发 `1` 会与既有行撞号,使版本顺序不可信、回滚目标可能指向另一条 + 记录的同号版本,且无人能发现、重启也修不回来)与**修复动作**(修数据源/驱动错误后重试写入),然后 + **原样抛出**,让事务回滚。一次故障只说一次,恢复时补一条 `info`。 + + ### `@objectstack/metadata` 新增子路径导出 `@objectstack/metadata/errors` + + 判别器 `isMissingTableError()`(#4728/#4825 家族)此前是 `@objectstack/metadata` 的内部工具,而本次 + 消费者在另一个包。三个选项中选了「从现有归属地**显式导出**」:在 `metadata-protocol` 里复制一份会重建 + #4825 刚消灭的双源问题(同一个问题两套「哪些驱动错误算良性」的词汇表,谁先学会一个驱动怪癖谁就先漂移); + 下沉到公共依赖本轮不可行(`packages/spec` 冻结、`packages/types` 有并行改动),且本次导出并不妨碍维护者 + 之后再下沉。 + + 新增的是一个**叶子子路径**而不是包入口导出:`@objectstack/metadata` 的根入口会拖进 manager、全部 + loader 与其 YAML/文件系统依赖,只为一个 40 行谓词付这个重量,正是把下一个作者推回「复制一份」的原因。 + `@objectstack/metadata/errors` 只 re-export 一个叶子模块,跨包依赖边因此仍是叶子边,也是将来下沉时 + 一个可 grep、可删除的单点。仅导出 `isMissingTableError`;同族的 `isSchemaAlreadyExistsError` 在包外 + 没有消费者,保持内部(导出一个无人 import 的符号是白许的承诺)。 + + 无 API 破坏、无 schema 变更、无 `packages/spec` 改动。 + +- e92e2c3: fix(metadata): `unregister()` invalidates the list cache AFTER the storage delete lands (#5259) + + `MetadataManager.unregister()` dropped the registry entry and called + `invalidateListCache(type)` **before** awaiting `loader.delete()`. Those two steps + are separated by a real await window — one DB round-trip per writable loader — and + inside it the manager held a state that exists nowhere else: **registry already + empty, loader not yet empty**. `list()` merges the two, so a read arriving in that + window missed the just-cleared cache, assembled the still-stored row into its + answer, and memoized it as a _complete_ read — the full 30s healthy TTL, because no + loader threw and #5184's 2s degraded TTL therefore never applied. + + Nothing invalidated again once the delete landed (`notifyWatchers()` does not touch + `listCache`), so an item that was gone from storage kept being enumerated for up to + half a minute. `list()` is the enumeration seam behind `GET /api/v1/metadata/:type`, + the Studio left rail, sync/export and every consumer that decides existence from a + declared set — and `get()`, which never reads that cache, said the item was gone the + whole time. For a gating type (`permission`, `api`) the two faces of one manager + answered opposite questions about whether a declaration exists. + + **Fixed by ordering, not by an extra invalidation.** `register()` never had this + defect because it writes the registry _first_ and the registry outranks every loader + in the merge, so its own save window already shows the post-write state. The + invariant is therefore not "invalidate early" but _invalidate last, once every store + already holds the announced state_. `unregister()` now deletes from storage first, + then drops the registry entry and invalidates with **nothing awaited between them**, + then publishes and announces — #5219's invalidate-before-notify discipline unchanged. + A `list()` racing the delete now either sees a coherent pre-delete state (the delete + has not landed and has not been announced — that answer is the truth) or the + post-delete state; it can no longer cache the pre-delete answer past the delete. + + This composes with #5253's single-flight rather than duplicating it: a read still + _in flight_ when the delete lands cannot be reached by dropping `listCache` — it has + not written its entry yet and would write the pre-delete answer afterwards. + `invalidateListCache()` also retracts that read's `inflightListReads` registration, + so it resolves for the callers already waiting on it but loses the right to memoize, + while a caller arriving later starts a fresh read. + + **A storage delete that fails is now loud.** It used to `logger.warn('Failed to +delete …')` and continue. Per AGENTS.md "Degradation log levels" this is + durability/consistency degradation, not functional: `unregister()` resolves + normally, the caller is told the delete succeeded, and the surviving row is read + straight back out of storage by the very next `list()`/`get()` — permanently, since + nothing retries it. It now logs at `error`, once per un-deleted item, naming the + consequence and the fix. The registry entry is still dropped in that case, + deliberately: the loader still holds the row so the item is served either way, and + keeping the entry would only pin an in-memory copy on top of a stored row nobody + maintains — dropping it makes the next read fall through to storage, which is the + actual truth after a failed delete, and makes it visible immediately instead of at + the next restart. + + No API change. `unregister()` still resolves rather than throwing when a loader + refuses the delete. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + - @objectstack/metadata-fs@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 5b56b702c2..20f8fccf4f 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 da28d31a05..a2e78a2584 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,674 @@ # @objectstack/objectql +## 17.0.0-rc.3 + +### Minor Changes + +- 3905c00: feat(objectql)!: a predicate bulk write evaluates and fires after-hooks PER ROW (#5038) + + The 2026-08-04 maintainer ruling on #4800 / #4862, recorded as ADR-0058's + bulk-write addendum: **a bulk write is N record changes**, so every record-scoped + declaration on it is evaluated per row — `record` = that row's state, `previous` = + that row's pre-write state. Validation predicates have worked this way since + #3106; hook `condition`s and the record-change flow triggers riding the same + lifecycle hooks now join them. + + **What was broken.** A `multi: true` update reaches `driver.updateMany`, which + resolves an affected COUNT. The lifecycle hook fired **once**, `previous` was + never assigned (only the single-id branch fetched a prior row), and `record` + degraded to the write's bare payload. So the transition condition the docs, the + formula skill and ten showcase flows all teach — + `status == "done" && previous.status != "done"` — could not be evaluated on a + bulk write. Hook conditions rejected the write (#4775/#5037); record-change flow + triggers were **silent**, firing zero times or once for a record that did not + exist. A missing audit row is the one failure nobody goes looking for. + + **What changed.** The engine's bulk `update` / `delete` branches now read the + matched row set **once** — the same `driver.find` #3106 already issues, with + "this object has after-hooks" added to its demand test — and dispatch + `afterUpdate` / `afterDelete` once per matched row, each on a context with the + **single-record shape**: `input.id` = the row, `previous` = its pre-image, + `result` = its state. That is #2922's batch-INSERT ruling restated, and it is why + this fix has no code in the consumers: `hook-wrappers`' `record`/`previous` + bindings, the record-change trigger's context builder and plugin-audit's diff all + read those same fields and became correct at the producer. + + - **Per-row dispatch is uniform across after-hooks.** It is deliberately NOT + keyed on whether a condition mentions `previous` — the ruling rejected that as + a hidden rule that would make a hook's firing count depend on its condition + text. + - **`ctx.result` per row is the ROW**, composed as `row ⊕ payload` from the + pre-image already in hand, so the batch still costs one extra query, not one + per row. A bulk DELETE has no post-state: its per-row context sets no `result`, + and consumers fall back to `previous`. + - **`onError` needed no new meaning** — it governs a handler on a record-scoped + context, which is now what it always gets: `abort` fails the operation, `log` + swallows that row and the batch continues. + - **A ceiling, enforced as a refusal.** Past 10 000 matched rows a predicate + write against an object with after-hooks is rejected _before_ the driver call + (`ERR_BULK_PER_ROW_HOOK_LIMIT`), so nothing is written. It is never downgraded + to one dispatch for the batch — that would skip the hook for N-1 rows silently. + + **Breaking for hook authors, in the direction the contract declares.** An + after-hook on an object that takes predicate writes now runs once per matched row + instead of once per batch: a notification hook sends N messages, a + cache-invalidation hook runs N times. Objects with no after-hooks are untouched + and pay for no extra read. The write's own contract is unchanged — a predicate + write still resolves the affected count and still publishes ONE aggregate + `data.records.updated` (#4639). + + **`before*` hooks stay batch-scoped, and that is not a gap.** `beforeUpdate` / + `beforeDelete` fire once for the whole batch because they may still rewrite the + payload, and one `updateMany` carries one payload. #5037's `HookConditionError` + and its `limitation` discriminator therefore **survive, rescoped to that + dispatch** — with a message that no longer promises an expiry that has already + happened, names the phase as the reason, and points at the matching `after*` + event where the same condition evaluates per row as authored. It also now names a + record-change flow trigger as a real route: #5037 refused to, on measured + evidence that the trigger shared the same unbound `previous`; that fact changed. + + Docs (`data-modeling/formulas.mdx`) and `skills/objectstack-formula` §5 are + updated to teach one transition shape for both write forms, with the `before*` + exception called out. + +- afa6aa5: feat(objectql): export the delete-dispatch contract so test doubles can be pinned to it (#4550) + + A test double that is **looser** than the implementation it replaces converts a + green suite into no suite at all — silently, and on exactly the paths a double + was introduced for, which are the paths that were hard to test, which are + usually where the contract is densest. #4434 is the worked example: + `DELETE /api/v1/sharing/rules/:idOrName` answered 500 for every rule and both + address forms it advertises, from the day it was written, while + `deleteRule drops rule + all its grants` asserted success against it the whole + time — against a fake engine whose `delete` accepted the one call shape + `ObjectQL.delete` refuses. + + `ObjectQL.delete`'s dispatch decision now lives in one exported place instead of + being re-derived by every fake: + + ```ts + import { assertEngineDeleteDispatch } from '@objectstack/objectql'; + + async delete(object: string, options?: any) { + assertEngineDeleteDispatch(options); // refuses what a real server refuses + … + } + ``` + + New exports, all pure and side-effect free: + + - `resolveEngineDeleteDispatch(options)` → `{ kind: 'by-id', id }` | + `{ kind: 'multi' }` | `{ kind: 'reject', message }` — what the engine will do + with this call, without doing it. + - `assertEngineDeleteDispatch(options)` — throws exactly what the engine throws + on `reject`, returns the dispatch otherwise. This is the line a fake engine's + `delete` opens with. + - `scalarDeleteId(options)` — the SCALAR `where.id` or `undefined`. The half a + hand-written mirror drops: `where: { id: { $in: [...] } }` looks like an id + and is a multi-row predicate, so the engine rejects it without `multi`. + - `ENGINE_DELETE_REJECT_MESSAGE`, `ENGINE_DELETE_DISPATCH_CASES` — the message + and the shared conformance case-set, the same role + `packages/spec/src/data/*-conformance.ts` plays for drivers. + + `ObjectQL.delete` itself reads `resolveEngineDeleteDispatch`, so a double that + imports it cannot be looser than the engine, ever — that is the property, and + it is the one a hand-mirrored `if` can only have until somebody edits one side. + No runtime behaviour changes: the same three verdicts, over the same inputs, + proved case-by-case against the real engine in + `engine-delete-dispatch.test.ts`. + + Repo-side, `pnpm check:engine-double-contract` (wired into `lint.yml`) finds all + 39 fake ObjectQL engines in the repo, holds new ones to this predicate, and + keeps the 30 not yet converted in a measured, shrink-only baseline. + +- 5d3ced9: fix(objectql,lint)!: a `json_schema` validation rule's `format` keyword is now ENFORCED — records that passed before can start failing (#5029) + + > **⚠️ BEHAVIOUR CHANGE ON DEPLOYED DATA — READ BEFORE UPGRADING.** A `format` + > keyword inside a `json_schema` validation rule used to enforce **nothing**. + > It now enforces. If any deployed object carries such a rule, writes that + > succeeded on the previous version can be **rejected** after this upgrade — + > including writes from flows, seeds, imports and integrations, not just the UI. + > Nothing about the metadata changed; the runtime simply started honouring what + > the metadata always said. See "Before you upgrade" below. + + ## What was broken + + `packages/objectql/src/validation/rule-validator.ts` built its shared ajv as + `new Ajv({ allErrors: true, strict: false })` and stopped there. In ajv 8 the + `format` keyword is **not built in** — it ships in the separate `ajv-formats` + package — and under `strict: false` an unregistered format is not an error: ajv + logs one line at compile time and **drops the keyword**. + + So this rule: + + ```ts + { + type: 'json_schema', + name: 'support_config_shape', + field: 'support_config', + message: 'Support config is invalid.', + schema: { + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'], + }, + } + ``` + + compiled fine, ran on **every** write, enforced `type` and `required` — and + enforced **nothing at all** for `format`. `{ email: 'not-an-email' }` was + accepted, for every record, forever. The only signal was a stderr line at + compile time naming no rule and no object. + + This is the #4649 / #4762 family one level in, and the partial failure is what + made it nasty: the rule visibly rejects a bad `type` / missing `required` + payload in dev, so it reads as _working_ while the `format` half never fires. + `format` is also one of the most reached-for JSON Schema keywords (`email`, + `uri`, `uuid`, `date`, `date-time`, `ipv4`), so this was not an exotic corner — + and it is exactly the shape an AI writing metadata reaches for first. + + ## What changed + + - **`@objectstack/objectql`** now depends on `ajv-formats` and registers it on + the shared instance (`addFormats(ajv)`). The **default (full)** format set is + used deliberately: `fast` mode trades correctness for speed on precisely the + formats authors reach for most, and a format that "mostly" matches is the same + declared ≠ enforced defect with a smaller hole. + - **`@objectstack/lint`** — the #4762 publish gate + (`validate-rule-compilability.ts`) compiles every `json_schema` rule with the + SAME ajv environment the runtime uses, on purpose, so it registers the same + plugin. This is not cosmetic parity: `ajv-formats` also installs the + `formatMinimum` / `formatMaximum` keywords, so a gate without it treats them + as unknown keywords (`strict: false` ⇒ silently ignored) and would publish a + schema the runtime then refuses to compile — a rule that passes review and + enforces nothing, which is the failure that gate exists to prevent. The parity + test now reads the plugin registration out of the runtime's source, so the two + cannot drift apart silently. + + **Authoring is unchanged.** `format` stays a legal, publishable JSON Schema + keyword; the publish gate does not refuse it (option 2 on #5029 was considered + and rejected — refusing standard JSON Schema would push authors into private + spellings). What changed is only that the declaration is now true. + + ## Before you upgrade + + 1. Find the rules at risk: any `object.validations[]` entry with + `type: 'json_schema'` whose `schema` contains a `format` key, at any depth + (including inside `$defs` / `$ref` and a `conditional`'s `then` / + `otherwise` branch). + 2. For each, audit the existing column against that format. Rows already stored + are **not** re-validated — nothing is rejected retroactively, and no + migration runs — but the **next write that touches the field** is checked, + which includes an unrelated PATCH that merely resends the JSON blob. + 3. If a format was aspirational rather than real, remove that `format` key (or + relax it) _before_ upgrading. Deleting the keyword is now a meaningful, + visible act rather than a no-op. + + ## Known limitation, recorded deliberately + + A **misspelled** format name is still ignored. `format: 'emial'` compiles under + `strict: false` — ajv logs `unknown format "emial" ignored` and drops it — in + both the runtime and the publish gate, so a typo still enforces nothing. That + behaviour is unchanged here and pinned by test in both packages, so it is a + known boundary rather than an oversight; closing it is an authoring-time + decision of its own and is tracked separately. + +- 7c2f7dd: fix(objectql,service-queue): a `lifecycle` settings override can no longer undercut a consumer's retention floor (#5195) + + ADR-0057 P4 lets an operator override any object's retention window per + environment and per tenant through the `lifecycle` settings namespace. Until now + the only validation on that override was **does it parse** — and a retention + window is not only the operator's business: other code can depend on the rows + still being there. + + `sys_job_queue` is the worked example. `DbQueueAdapter` deduplicates publishes by + comparing a terminal row's `created_at` against its idempotency window, so the + dedup check only means anything while that row still exists; #5179 made the + ordering an invariant by refusing, at construction, an idempotency window longer + than the object's **declared** retention. A settings override the constructor + cannot see walks straight around it: + + ```jsonc + // lifecycle → retention_overrides + { "sys_job_queue": { "maxAge": "1h" } } + ``` + + completed rows are reaped an hour after they are written, publish keeps + deduplicating against 24h, and duplicate deliveries resume **with nothing in any + log**. + + **New: retention floors.** A consumer may now declare, at runtime, the shortest + window its own contract survives: + + ```ts + lifecycle.registerRetentionFloor("sys_job_queue", { + policy: "retention", // or 'ttl' + minWindowMs: 24 * 60 * 60 * 1000, + declaredBy: "com.objectstack.service.queue", + consequence: "…what silently breaks below it", + remedy: "…the settings change that makes an override legal", + }); + ``` + + - An override below the floor — **global or tenant-scoped** — is **rejected**, + and the declared window keeps running. Not clamped to the floor: clamping + would enforce a third number written in neither the declaration nor the + settings, and that number would move whenever an unrelated package changed + its floor. Rejection has exactly one fallback, the declaration, which is + already how an unparseable override resolves. + - The rejection is `error`-level and carries both the consequence and the fix, + because what it prevents leaves the system looking entirely healthy. It is + also on the sweep report as `LifecycleSweepReport.floorViolations` — machine- + readable, every sweep. + - A **declared** window below a registered floor is reported the same way and + still enforced: refusing to reap would trade a broken consumer contract for + the unbounded table #5179 just closed. + - Objects with no registered floor are completely unaffected — P4 overrides + behave exactly as before. + + Floors are runtime wiring, not spec surface (the same call ADR-0057's reap-guard + amendment makes), plus a reason of their own: the queue's floor **is** + `DbQueueAdapterOptions.idempotencyWindowMs`, a per-kernel construction option, so + a static key on the object's `lifecycle` block could only ever be a second copy + of it that drifts. No `packages/spec` change. + + `QueueServicePlugin` registers `sys_job_queue`'s floor on `kernel:ready`, + carrying the window the adapter was actually constructed with — so a non-default + `db.idempotencyWindowMs` is covered too. The ordering is now enforced from both + sides: the constructor rejects a too-long `idempotencyWindowMs`, the floor + rejects a too-short `maxAge`. + + New exports from `@objectstack/objectql`: `LifecycleRetentionFloor`, + `LifecycleFloorViolation`, plus `LifecycleService.registerRetentionFloor()`. + `LifecycleLoggerLike` gained an optional `error()` (absent ⇒ falls back to + `warn`), and `LifecycleSweepReport` gained `floorViolations`. + +- 5b8f95b: fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (#4889) + + `readonlyWhen: P\`parent.status == 'paid'\``— the documented "once the header +invoice is Paid, its lines are frozen" lock — was enforced **only in the client +grid**. The server-side strip bound`record`and`previous`and had no`parent`at all, so every parent-scoped predicate faulted, took the fail-open branch, and +the write landed anyway. On the reference app that meant one`PATCH` rewrote the + quantity and unit price of a settled invoice's line: HTTP 200, value persisted, + the grid still drawing the cell read-only. ADR-0057 D10 puts enforcement on the + server and makes the client courtesy; here only the courtesy layer enforced. + + **`parent` is now bound on the write path.** For a detail object — one declaring + exactly one `master_detail` relationship — the engine resolves the master record + and binds it as `parent` before the strip runs, on both the single-id and the + bulk (`multi: true`) update paths. A repointing write is judged against the + master it _lands on_, not the one it leaves. The read is gated on the payload + actually touching a parent-scoped predicate (decided from the parsed CEL AST, so + a field named `parent_id` costs nothing), and the bulk path batch-reads the + distinct headers in one query rather than one per row. + + **An unbindable scope no longer waives the lock.** A `readonlyWhen` that names a + root the operation could not bind now resolves to **locked** — the field is + stripped — instead of "not locked". "The platform could not check this" must not + mean "allowed" on a field the author declared frozen. This is deliberately the + narrowest possible carve-out from the fail-open policy the strip has always had: + a predicate that is merely _broken_ on the record (undeclared key, `null` + ordering overload, parse error, engine throw) still fails open exactly as + before, and `requiredWhen` / option `visibleWhen` are untouched. Recorded as an + addendum to ADR-0058's D5 fail-policy matrix, alongside the same narrowing + already made for validation predicates (#4649) and hook conditions (#4775). + + **And the runtime branch is a backstop, not the plan.** `objectstack compile` + now **rejects** a `parent`-scoped `readonlyWhen` on an object that declares no + `master_detail` relationship, or two of them (where the metadata does not say + which one is "the parent" and picking by declaration order would make a + data-integrity lock depend on field ordering). The common authoring mistake is + caught where it is cheap to fix, so it never reaches a runtime that has to judge + it — declared, not guessed. + + No metadata changes are required: an app whose parent-scoped locks were already + correct simply starts having them enforced. If you authored one on an object + with no single master, the build now names it. + +### Patch Changes + +- 4335497: fix(objectql): a bulk write blocked by a `previous` hook condition is told it is a VERSION limit, not an authoring mistake (#5037) + + #4775 made an unevaluable hook `condition` abort the operation, and #4861 gave + the predicate-bulk-write case its own sentence instead of a raw + `Unknown variable: previous`. What that sentence still said was + _"rewrite the condition without `previous`, or target the write at one record"_ — + written before the maintainer's 2026-08-04 ruling on #4800/#4862, and wrong in + its most important claim. The ruling settled the contract: **on a bulk write, + after-hooks and record-change flow triggers evaluate and fire per row** (recorded + as an ADR-0058 addendum, implemented by #5038). The author's transition condition + is legitimate; the engine is what is behind. Telling them to drop `previous` was + advising a silent semantic change — a transition ("just became done") becomes a + state test ("is done"), which fires on every row that was already done. + + The rejection now says what is actually true: + + - it names the batch and why there is no single prior record to bind, as before; + - it states this is a **current-version limitation**, cites the per-row contract + (ADR-0058 addendum, #4800/#4862) and the issue that retires the rejection + (#5038); + - it leads with the route that works today — target the write at one record, and + the same condition evaluates as authored — and prices the rewrite instead of + recommending it; + - it still refuses to point at a record-change flow trigger as a way out, which + remains verified rather than assumed: that trigger binds the same lifecycle + hooks and receives the same unbound `previous` on a bulk write (#4862). + + **Machine-readable, so a caller never parses the prose.** `HookConditionError` + gains `limitation?: 'bulk_write_previous_unbound' | +'bulk_write_stored_state_unavailable'` (exported as `HookConditionLimitation`) + alongside the existing `predicateBulkWrite` flag. It is deliberately _not_ named + `code`: ADR-0112 makes `error.code` a closed wire vocabulary + (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) and `rest-server.ts` promotes a + thrown error's `.code` onto the response envelope, so a `.code` here would mint + an unregistered wire code as a side effect. A code that needs to travel goes + through the ledger as a decision. + + **"Does this condition read `previous`" is now read off the parsed CEL AST** + (`collectCelRootIdentifiers`, the utility #4972's build gate already uses), + computed once at wrap time, with the old fault-text check kept as a fallback. + The diagnosis no longer depends on cel-js's wording, and it stays correct when + the evaluator faults on some other key the same condition reads. + `record.previous_status` is not a `previous` reference — the AST reports roots, + not member names — so it keeps the declared-field diagnosis, whose remedy is the + right one there. + + Unchanged, and pinned by tests: single-record writes (bound `previous`, condition + evaluates, handler runs), bulk writes whose conditions do not name `previous`, + the plain undeclared-key typo report on a bulk write, and fail-loud itself — the + write still fails. Nothing here is an exemption; it is the same rejection with a + diagnosis attached. + +- 2b63a00: fix(objectql): a re-registered driver stops crying wolf, and a real name collision starts saying what it cost (#4773) + + Every boot printed one line into `⚠ Boot diagnostics`: + + ``` + WARN Driver already registered, skipping {"driverName":"com.objectstack.driver.sql"} + ``` + + It was never an anomaly. The standalone `default` datasource is registered + twice, on two legs of one round trip, and traced end to end it is the **same + object instance** both times: + + 1. `DatasourceConnectionService.attemptConnect()` builds the default driver and + registers it (`isDefault: true`), driven by `DefaultDatasourcePlugin.init()`; + 2. that plugin republishes the instance it just read back out of the engine as + the `driver.` kernel service — the surface `os migrate` and serve's + storage detection resolve the primary DB through — and + `ObjectQLPlugin.start()`'s `driver.*` discovery loop bridges every such + service into the engine, handing back the driver it already holds. + + Nothing is decided and nothing is discarded, so `registerDriver` now reports + that at `debug`. A no-anomaly line on every single boot does not belong at + `warn`; it only teaches operators that `warn` means nothing. + + The reason this is not a blanket downgrade: the same `warn` also covered the + case that genuinely matters — **two different driver instances claiming one + name**, where "skipping" silently drops one of two configurations (connection + string, pool, tenant scoping, capability set) while every query bound to that + name keeps working against the winner. The two are now told apart by object + identity: + + - **same instance** → `debug`, nothing happened; + - **different instance under a held name** → still `warn`, now naming which + configuration was KEPT and which was DISCARDED (with both versions) so the + operator can tell what is actually in force; + - **same instance re-registered with `isDefault` while another driver holds + that role** → `warn`, because the caller's intent is otherwise dropped in + silence. + + Registration behaviour is unchanged in all three cases — first registration + still wins. Only which of them is worth an operator's attention changed. + +- 1cae606: fix(objectql): resolve the `NOW()` defaultValue token in the engine, so it works on every datasource (#4597) + + `Field.datetime({ defaultValue: 'NOW()' })` only ever worked on SQL. The engine's + `applyFieldDefaults` special-cased exactly two `defaultValue` shapes — the + Expression envelope and the `current_user` token — and passed everything else + through verbatim, so the four characters `NOW()` were written into the record as + a **literal string**. The SQL driver hid that: `SqlDriver.formatInput` carries an + insert-time safety net that swaps any `NOW()` string for a real ISO timestamp + before it hits the wire. Memory and MongoDB have no such net. + + This is the mirror image of #4560. There, `current_user` was known to the engine + and not to the DDL, so the DDL stored the token text. Here, `NOW()` was known to + the SQL driver and not to the engine — same crack, opposite side. It surfaced two + ways: + + - On a **validated** field the insert was **rejected outright**, by the engine's + own write validator, against a value the engine itself had just filled in: + `ValidationError: … must be a valid datetime (ISO-8601)`. Every insert omitting + such a field failed, with an error naming a field the caller never sent. + - On a `readonly` / `system` field — which `validateRecord` skips, i.e. the ~100 + `created_at` / `updated_at` declarations across the platform objects — nothing + was rejected at all and the string `NOW()` was **stored**. + + `applyFieldDefaults` now resolves the token itself, from the same per-insert + `now` snapshot it already passes to Expression defaults, so every field defaulted + in one insert (and every row of one batch) carries the identical instant. The + spelling it matches is the spec's (`isNowDefaultToken` from + `@objectstack/spec/data`, case-insensitive and whitespace tolerant), the same + predicate a driver's DDL consults — the engine does not re-derive its own. + + The token resolves into the shape the field's **declared type** stores, which is + what `SqlDriver.nowColumnDefault` already emits per type (ADR-0053), so no + datasource disagrees about the stored form: + + | field type | stored value | + | --------------------------------------------------- | ----------------------------------------------------------- | + | `date` | `YYYY-MM-DD` (UTC calendar day) | + | `time` | `HH:MM:SS[.fff]` (UTC wall clock; a zero `.000` is trimmed) | + | `datetime`, and any non-temporal field that opts in | `YYYY-MM-DDTHH:MM:SS.sssZ` | + + No authoring change: `defaultValue: 'NOW()'` is the same declaration it always + was, and a caller-supplied value is still never overwritten. What changes is that + it now means the same thing on memory and MongoDB as it always did on SQL. + Records written on a non-SQL datasource before this fix may hold the literal + string `NOW()` in those columns; they are not rewritten. + + Both driver-side mechanisms stay, unchanged, as defence in depth: `formatInput`'s + safety net (now unreachable from the engine's insert path) and the native column + DEFAULT, which still serves writes that bypass the engine entirely — the same + division of labour `current_user` has. + +- 2ddba89: fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262) + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — therefore reads `false` from + `resolveMultiOrgEnabled()` while running a fully mounted organization wall. + #5233 corrected two sites in `plugin-auth`; a census found eight more, all + written before that function's doc comment was corrected. Third recurrence of + the shape (cloud#1020, #5233). + + Each site was judged separately for **which** posture answers its question — + what the operator REQUESTED, or what the `tenancy` service reports is actually + IN FORCE — rather than converted mechanically: + + - `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the + REQUESTED posture (it is constructed below the kernel, with no service + registry to ask). The `organization_id` column was always provisioned; what + diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's + hottest predicate unindexed while SecurityPlugin compiled that same wall. + - `plugin-dev` — whether to load the enterprise `@objectstack/organizations`. + REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall, + so asking whether the wall is up would be circular. A posture-only dev stack + previously never loaded the package at all and served traffic unwalled. Its + diagnostic now names the posture that was requested instead of asserting + `OS_MULTI_ORG_ENABLED=true` at an operator who never set it. + - `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture, + via the `tenancy` service. These ask "will the per-org replay run instead of + me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so + keying on the request would defer to a replay that can never happen. Walled + deployments previously inline-seeded exactly the NULL-organization rows the + code's own comment exists to avoid. + - `cloud-connection` marketplace local install (install-time seed + rehydrate + heal) — EFFECTIVE posture, same reasoning. The install path is a write path: + a walled deployment wrote every sample row with no `organization_id`, landing + the app's data outside the wall its own reads apply. + - `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no + kernel to ask, and a suppressed warning is the costlier error for a + diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a + process-level fact into a per-instance verdict on whichever write landed + first. The gate now resolves live, which is affordable because + `auditMissingTenant` consults it only after the `tenantId` early-out. + - `cli` `os verify` — REQUESTED posture. This one produced a green verification + run over an unverified property: a posture-only deployment silently skipped + every multi-tenant proof and exited 0. + + **No configuration change is needed anywhere.** Deployments setting only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + belt-and-braces configuration stays valid. Deployments that set only + `OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is + unchanged at every site; only the knob each one reads is corrected. + +- 37a8f2b: fix(objectql): the static-`readonly` write strip now logs its consequence and its remedy + + Writing a `readonly: true` column from server-side code — a cron job or background + task reaching the engine through `ctx.getService('data')` — dropped the value and + reported success. The only trace was: + + ``` + WARN Field 'work_duration' is read-only — ignoring incoming change (#2948) + ``` + + which says what the engine did, not what it cost the caller or how to fix it. The + downstream symptom is a field that persists fine through every REST path and never + persists from cron (os-project-titanwind-ehr#750), which reads as "cron is broken" + rather than "the value was stripped". The strip now names the object, states that + the update was **committed without the field**, and carries both remedies: trusted + server code declares itself with `{ context: { isSystem: true } }`, and any caller + can detect drops programmatically with `options.onFieldsDropped` (the machine-readable + strip signal that has existed since #3407 — one event per strip pass, with `fields` + and `reason`). + + The level stays `warn`, deliberately: this seam cannot distinguish a hostile REST + body forging `created_by` from trusted server code, because `ExecutionContext` + carries no origin marker and `isSystem` — the only trust bit — is precisely the + exemption. `error` would make the error log client-triggerable; `debug` would restore + the silent drop. + + Behaviour is unchanged: what is stripped, what survives, and what `onFieldsDropped` + reports are all identical. Documented in the [security protocol + page](/docs/protocol/objectql/security) — strip condition, the caller-supplied-keys + scope, why a `beforeUpdate` hook's backfill is exempt (the key snapshot is taken at + engine entry, before hooks run), and the `isSystem` convention for plugin writes — + and pinned in `engine-readonly-strip-signal.test.ts`. + +- db2ea82: fix(objectql): a single-record `delete()` binds `hookContext.previous` — the pre-image the contract has always promised (#5272) + + `HookContext.previous` is documented in the spec as _"the state of the record + BEFORE the operation (**for update/delete**)"_, and `update()` has bound it all + along. `delete()` never did. `previous` was `undefined` in **both** + `beforeDelete` and `afterDelete`, for every single-record delete, on every + object. + + That is not a cosmetic gap. Since #4775 a condition that cannot be evaluated + **fails the operation**, so a legal, contract-shaped delete-side hook: + + ```ts + { events: ['afterDelete'], condition: "previous.status == 'done'" } + ``` + + rejected _every_ single-record delete of that object — and reported it through + the generic branch (`Unknown variable: previous`), which reads like the author + misspelled a key. The key was fine; the engine never bound it. Same shape as + #5037: a platform gap surfacing as the author's mistake. + + **Why now.** #5038 made a predicate bulk delete dispatch `afterDelete` once per + matched row, each carrying that row's own pre-image. The single-record path + still bound nothing, so it became strictly _worse_ than the bulk path — the + exact inversion the #4800/#4862 ruling ("an author writes the hook once; single + and bulk mean the same thing") exists to prevent. + + **What changed.** `delete()` now takes the doomed row's pre-image once, before + `beforeDelete` fires, and binds it to `hookContext.previous` for both phases — + so hook `condition`s, record-change flow triggers and delete-audit handlers all + see the deleted row. The read is demand-driven, exactly like `update()`'s: it + happens only when the object has a delete-side hook (either phase) or a roll-up + summary aggregating it. An object with neither pays nothing, and an object with + both phases pays **one** read, not two — the roll-up path's own separate + pre-image fetch has been folded into this one, and is now the same raw driver + read `update()` already feeds the summary recompute. + + Nothing is fabricated: if the row is not there, `previous` stays **unbound** + rather than becoming `{}`/`null`, so a condition reading it still faults loudly + instead of answering for a record nobody read (#4649/#4775). The batch dispatch + of a predicate delete still carries no `previous` — it stands for N rows — and + its per-row `afterDelete` contexts are unchanged. + + Upgrade impact: a delete-side hook whose `condition` reads `previous` starts + evaluating instead of rejecting the write, and delete-side handlers start + receiving `ctx.previous`. If you worked around the gap by testing + `ctx.previous == null` to detect "this is a delete", that test now answers + differently — read `ctx.event` instead. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [e96ad55] +- Updated dependencies [2e284b2] +- Updated dependencies [75bb3af] +- Updated dependencies [0161c7f] +- Updated dependencies [1f82d1e] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [da538b1] +- Updated dependencies [79822b5] +- Updated dependencies [15e61fb] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [1d29e6d] +- Updated dependencies [c5a5996] +- Updated dependencies [5ea8e1e] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [dca25e1] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [38f53a0] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/metadata-protocol@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index ca0b5ddcca..558a70d633 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 adf9c4d822..16a9540c3b 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/observability +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index acc44a151c..1db2caedab 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 d1a899f072..bc7c959a58 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,152 @@ # @objectstack/platform-objects +## 17.0.0-rc.3 + +### Minor Changes + +- e98fb14: fix(service-queue): `sys_job_queue` no longer grows forever — `completed` rows expire on a declared 7-day retention (#5179) + + `DbQueueAdapter` marked a delivered message `status: 'completed'` and then + **nothing ever touched that row again**. `purge()` had zero production callers + (tests only), `purgeFailed()` is a manual dead-letter API, and the object + declared no lifecycle policy at all — so every queue delivery left a permanent + row, which since #5160 means one permanent row per queued email. + + `sys_job_queue` now declares an ADR-0057 policy and the platform + `LifecycleService` enforces it on its existing hourly sweep: + + ```ts + lifecycle: { + class: 'transient', + retention: { maxAge: '7d', onlyWhen: { status: 'completed' } }, + } + ``` + + **Only `completed` rows are swept.** `pending` / `running` are live work, and + `failed` / `dlq` are the dead-letter queue — they exist to wait for a human, so + they are never deleted automatically at any age. `listFailed()` / `replay()` / + `purgeFailed()` remain the only way a dead letter leaves the table. This is + also why the policy is `retention` (age + row filter) rather than a `ttl` on + `completed_at`: TTL has no row filter, and `dlq` rows stamp `completed_at` too. + + **No new configuration, and no new sweeper.** ADR-0057 §3.3 puts one reaper in + the platform rather than one per plugin — the same call the sibling + `sys_job_run` (30d) already makes. Any kernel with a data engine already runs + it, its per-sweep `[lifecycle] sweep: … ~N rows reaped` line now accounts for + this table too, and the window is overridable per environment through the + `lifecycle` settings namespace without touching code. + + **The dedup window is now an enforced invariant, not a coincidence.** Publish + dedups against a terminal row by comparing its `created_at` to + `idempotencyWindowMs` (default 24h), and the reaper cuts off on that same + `created_at` axis — so retention (7d) ≥ dedup window is what keeps "duplicate + publishes inside the window are suppressed" true. `DbQueueAdapter` reads the + declared window (new export `completedRetentionWindowMs()`) and **throws at + construction** if `idempotencyWindowMs` is configured longer than it, instead of + silently degrading into duplicate deliveries days later. If you raise + `idempotencyWindowMs` past 7 days, raise the object's declared retention (or the + `lifecycle` settings override) to match — the error message names both numbers. + + `class: 'transient'` is deliberate: `telemetry`/`event`/`audit` classes + relocate their table to the dedicated `telemetry` datasource wherever one is + registered (ADR-0057 §3.6), and moving a live work queue's storage would be a + migration, not a cleanup. + +- f104bab: feat(plugin-email,platform-objects): `sys_email` carries headers and small attachments, so those messages become durably deliverable (#5177) + + Durable email delivery works from the **row**, not from the in-memory message: + `send()` publishes an `{ rowId }` job (#5160), the boot sweep re-reads rows + (#5161), and both end at `rowToNormalized`. So anything a `sys_email` row could + not carry, a row-based delivery would have dropped — and custom headers and + attachments were exactly that. The honest workaround was to refuse: a message + with either was pushed back onto inline delivery so that it would at least go + out whole, which closed the durable path to precisely the mail most worth + making durable (a signed receipt, a `List-Unsubscribe` header, an invoice PDF). + + `sys_email` now has two columns, and those messages are queueable. + + **`headers_json`** — the custom headers, as a JSON object. Written in both + delivery modes (it is audit evidence as much as delivery input) and rebuilt on + read. Headers are no longer a reason to fall back to inline delivery. + + **`attachments_json`** — attachments as a JSON array of + `{ filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? }`, + content base64 in `inline`. Written when the **combined raw size of one + message's attachments is within `SYS_EMAIL_ATTACHMENT_LIMIT_BYTES` (256 KiB, + exported from `@objectstack/plugin-email`)** — worst case ~350 KB of base64, so + a row stays bounded. Both arms of the declared `content: string | Buffer` + contract round-trip as the arm they were sent as: restoring a text attachment + as a Buffer would silently drop `charset=utf-8` from its MIME part and let the + recipient's client mis-decode a UTF-8 file, so `contentForm` records which one + it was. `cid` travels too — an inline `` is unusable without + it. + + **Over the limit, nothing changes.** The message is delivered inline exactly as + before, whole, and the row stores no attachment content; the reason is stated + at `info` (a bound, not a degradation — the worst outcome is today's + behaviour). Out-of-row storage for large attachments is #5172; `storageKey` is + declared now so that lands as a new _producer_ rather than a data migration. + + Rows written before these columns exist read exactly as they did. A column that + is present but does not describe what it claims — malformed JSON, a size or + hash that disagrees with the content, a missing `contentForm` — is **rejected**, + and the row lands at `failed` carrying the reason, rather than being delivered + with a part quietly missing. + + The `sys_email` schema change is additive (two optional textarea columns); no + migration is required and default inline delivery is unchanged. + +### Patch Changes + +- 1b9a53b: plugin-email: large attachments (>256 KiB) now get durable queue delivery, with their content held out of the `sys_email` row + + A message whose attachments exceeded the in-row budget was pushed back onto inline delivery — whole, but with none of the durability queue delivery exists to provide, which meant the platform was weakest about exactly the mail that matters most (a signed contract, an exported report). Its content now goes to the `file-storage` capability, the row records a `storageKey` plus the audit metadata, and the queue worker fetches the content back to rebuild the message. + + - **Zero migration.** `attachments_json` declared `storageKey` from the start; this adds the producer and the reader. Attachments at or under `SYS_EMAIL_ATTACHMENT_LIMIT_BYTES` still go in the row exactly as before, and the boundary includes equality. + - **The row stays an audit log, not a blob store.** `filename` / `contentType` / `size` / `hash` stay on the row permanently; the content is a delivery artifact and is deleted a grace window (24h) after the row reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`. Reclamation is a delayed `email.attachment.reclaim` queue job that carries the storage keys, so a row deleted in the meantime reclaims its content instead of orphaning it. + - **Nothing degrades silently.** No `file-storage` capability, or an upload that fails, keeps today's behaviour — inline delivery of the whole message — and says which of the two it was and how to fix it. On the way back, content that cannot be fetched (outage, missing object, no capability on the worker, truncated or substituted bytes) fails the row loudly; a message is never delivered without an attachment it declares. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 7a77c5a873..8b75581d5d 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.2", + "version": "17.0.0-rc.3", "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 33eb02d1a0..1c0c8c9549 100644 --- a/packages/plugins/driver-memory/CHANGELOG.md +++ b/packages/plugins/driver-memory/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/driver-memory +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/plugins/driver-memory/package.json b/packages/plugins/driver-memory/package.json index 288bd3b0df..55aabc8cae 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.2", + "version": "17.0.0-rc.3", "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 f2504ac4a4..c045eedec1 100644 --- a/packages/plugins/driver-mongodb/CHANGELOG.md +++ b/packages/plugins/driver-mongodb/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/driver-mongodb +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/plugins/driver-mongodb/package.json b/packages/plugins/driver-mongodb/package.json index 8bb43d298c..50e63930e6 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.2", + "version": "17.0.0-rc.3", "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 b21327d23d..88ece2a9e3 100644 --- a/packages/plugins/driver-sql/CHANGELOG.md +++ b/packages/plugins/driver-sql/CHANGELOG.md @@ -1,5 +1,322 @@ # @objectstack/driver-sql +## 17.0.0-rc.3 + +### Minor Changes + +- 4addd9d: feat(driver-sql)!: organization-scoped uniques are NULL-safe — `COALESCE(organization_id, '__global__')` key part + `unique: 'organization'` on declared indexes (ADR-0120 D3/D4, #5030) + + SQL UNIQUE is NULL-distinct, so the `(organization_id, field)` composite #3696 + introduced enforced **nothing** on rows whose organization is NULL — which on a + single-tenant stack (where the kernel injects the column and never fills it) is + **every row**: field-level `unique: true` was a silent no-op there, measured in + #5030. Per ADR-0120 D3, every organization-scoped unique now materializes its + organization key part as `COALESCE(organization_id, '__global__')`: NULL-organization + rows collapse into one platform bucket, unique among themselves; non-NULL rows + are untouched. Storage stays NULL — the sentinel exists only inside the index + key, and it is the same word the autonumber sequence table already uses + (`GLOBAL_TENANT`), so a constraint-violation error reads as "the platform + bucket collided", not as corrupt data. + + What changes, concretely: + + - **Field-level `unique: true`** (and the new explicit synonym + `'organization'`) on a tenant-scoped object → composite + `(COALESCE(tenantField, '__global__'), field)`. `unique: 'global'` and + tenant-less objects are unchanged. + - **Declared indexes gain the ADR-0120 D1 scope vocabulary at the driver**: + `unique: 'organization'` prepends the NULL-safe organization key part to the + listed columns (degrading to the listed columns on a tenant-less object; a + listed tenant column is made NULL-safe in place instead — the S6 respelling). + `unique: true` / `'global'` on a declared index stays **verbatim** — the + #3696 contract, now the `'global'` arm; the nine engine dedup/idempotency + keys keep their exact physical shape. (The spec/lint side of the vocabulary + lands separately via #4986; the driver deliberately merges first.) + - **Drift detection reads both sides through one normalization** + (the #4884 discipline, extended to the tenant key part): the physical + `COALESCE(organization_id, )` form is attributed to the column, + compared **literal-agnostically**, and recognised as the sync's own + vocabulary — a healthy database reports zero drift on every dialect. + - **Existing bare composites migrate through the ceremony (ADR-0120 D4)**: + `(organization_id, X) → (COALESCE(organization_id, '__global__'), X)` + surfaces as a `recreate_index` drift op — a pure tightening — gated by a + **duplicate pre-flight probe**. Clean probe → the op grades `safe` and dev + `autoMigrate: 'safe'` / a plain `os migrate apply` applies it. Duplicates + (data the void constraint wrongly admitted) → the op is **blocked** with a + per-group row report, the old index stays in place, and apply re-probes so + even `--allow-destructive` cannot drop a constraint whose replacement is not + creatable. Deduplicate, re-plan, apply. + - **`'__global__'` is reserved at the organization-minting seam** + (plugin-auth): an organization whose id or slug equals the sentinel is + rejected at creation with a prescriptive error (ADR-0120 D3 guardrail). + + Migration note for operators: on databases with pre-existing + organization-composite uniques, the first `os migrate plan` after upgrading + shows one `recreate_index` per affected index. On healthy data it auto-applies + in dev and is a no-op content-wise; a blocked op means the #5030 defect + admitted real duplicate rows — resolve the listed rows first. MySQL < 8.0.13 / + MariaDB cannot express the functional key part: the driver degrades to the + bare composite, says exactly what is not enforced at `error` level, and keeps + reporting the tightening as drift for after the server upgrade. + +### Patch Changes + +- d9971d3: fix(driver-sql): `$field` 跨字段比较改为按 ADR-0112 响亮拒绝,不再抛裸 TypeError + + `{ amount: { $gt: { $field: 'budget' } } }`(spec `FieldReferenceSchema`,由 `compileCelToFilter` 在转译含字段间比较的 CEL 权限/RLS 规则时产出)此前被 SqlDriver 当作**绑定值**交给驱动,sqlite 抛出无 `code`、无 `status` 的裸 `TypeError` —— 落在 `INVALID_FILTER` 信封之外,到客户端表现为不透明的服务端错误。更隐蔽的是列表位置:`$in` / `$between` 里的 `$field` 成员连报错都没有,直接静默返回零行。 + + 现在两者都以完整信封拒绝(`error.code = INVALID_FILTER`、HTTP 400、无 `[sql-driver]` 前缀),报错点名字段、运算符与被引用字段,并说明跨字段比较**当前仅内存求值路径(`matchesFilter`)支持**。三个比较发射点统一处理,Filter Protocol 与数组三元组两种写法得到同一答案。 + + 同一处闸门补上了 issue 指出的通用臂:**已知运算符 + 无法绑定的值形态**(标量比较位上的普通对象 / 数组)此前同样是裸 `TypeError`,现在也返回 `INVALID_FILTER`。`$in` / `$nin` / `$between` 的正常数组绑定不受影响。 + + `FieldReferenceSchema` 声明保留,JSDoc 补注执行支持面(内存求值 ✅ / SQL 下推 ❌ 响亮拒绝);SQL 列对列编译实现见 #5222。 + +- 2ddba89: fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262) + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — therefore reads `false` from + `resolveMultiOrgEnabled()` while running a fully mounted organization wall. + #5233 corrected two sites in `plugin-auth`; a census found eight more, all + written before that function's doc comment was corrected. Third recurrence of + the shape (cloud#1020, #5233). + + Each site was judged separately for **which** posture answers its question — + what the operator REQUESTED, or what the `tenancy` service reports is actually + IN FORCE — rather than converted mechanically: + + - `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the + REQUESTED posture (it is constructed below the kernel, with no service + registry to ask). The `organization_id` column was always provisioned; what + diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's + hottest predicate unindexed while SecurityPlugin compiled that same wall. + - `plugin-dev` — whether to load the enterprise `@objectstack/organizations`. + REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall, + so asking whether the wall is up would be circular. A posture-only dev stack + previously never loaded the package at all and served traffic unwalled. Its + diagnostic now names the posture that was requested instead of asserting + `OS_MULTI_ORG_ENABLED=true` at an operator who never set it. + - `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture, + via the `tenancy` service. These ask "will the per-org replay run instead of + me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so + keying on the request would defer to a replay that can never happen. Walled + deployments previously inline-seeded exactly the NULL-organization rows the + code's own comment exists to avoid. + - `cloud-connection` marketplace local install (install-time seed + rehydrate + heal) — EFFECTIVE posture, same reasoning. The install path is a write path: + a walled deployment wrote every sample row with no `organization_id`, landing + the app's data outside the wall its own reads apply. + - `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no + kernel to ask, and a suppressed warning is the costlier error for a + diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a + process-level fact into a per-instance verdict on whichever write landed + first. The gate now resolves live, which is affordable because + `auditMissingTenant` consults it only after the `tenantId` early-out. + - `cli` `os verify` — REQUESTED posture. This one produced a green verification + run over an unverified property: a posture-only deployment silently skipped + every multi-tenant proof and exited 0. + + **No configuration change is needed anywhere.** Deployments setting only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + belt-and-braces configuration stays valid. Deployments that set only + `OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is + unchanged at every site; only the knob each one reads is corrected. + +- f98fa65: fix(driver-sql): a fresh database no longer boots "drifted", and the drift + detector never points `--allow-destructive` at an index the framework created + (#4884) + + Booting `examples/app-showcase` on a brand-new empty SQLite file printed two + `[schema-drift]` warnings before the server was even ready, both about the + ADR-0048 overlay indexes the same boot had just created. Both were false, and + one of them was dangerous: + + > `[schema-drift] sys_metadata: index 'idx_sys_metadata_overlay_draft' UNIQUE +(type, name, organization_id) carries ObjectStack's generated naming but +matches no declared index (orphaned) — "os migrate apply --allow-destructive" +to drop it.` + + `idx_sys_metadata_overlay_draft` is the unique index enforcing **draft-overlay + uniqueness**. An operator following our own boot advice would have dropped a + live data-integrity guarantee to fix a problem that did not exist — and, worse, + learned to treat `--allow-destructive` as routine boot hygiene, which is exactly + what makes the _next_, real drift warning dangerous. + + Three fixes, in the driver's detector only (no metadata declaration changed — + `sys-metadata.object.ts` documents its four-column `indexes[]` entry as _the + fallback shape for drivers without the runtime migration_, and that contract + still holds for the drivers that rely on it): + + - **The index key is now read as written.** Introspection took the key from each + dialect's per-column catalogue view (`PRAGMA index_info`, `pg_attribute`, + `STATISTICS.COLUMN_NAME`), which describes an expression key as a NULL column + and nothing else. The canonical + `(type, name, organization_id, COALESCE(package_id,''))` overlay index + therefore arrived as three columns and was reported as a mismatch against its + own four-column declaration. SQLite and Postgres now parse the index + definition (`sqlite_master.sql` / `pg_get_indexdef`), MySQL reads + `STATISTICS.EXPRESSION` where the server has it, and `COALESCE(col, )` + is recognised as keying on `col` — which is what ADR-0048 uses it for: a plain + UNIQUE index treats NULLs as distinct, so package-less globals would not be + unique among themselves. + - **Partial predicates are captured.** A `WHERE`-restricted index is something + `syncDeclaredIndexes` can neither create nor rebuild, so the detector no + longer claims authorship of one, no longer calls it orphaned, and never + proposes a remedy it could not undo. + - **The driver keeps a ledger of the index DDL it executed.** An index this + process created through raw `execute()` — how `metadata-protocol`'s + `ensureOverlayIndex` issues its migration — is the framework's to manage. This + also covers the plain-index fallback the same migration takes on dialects that + reject partial indexes. + + Genuine drift is unaffected: an orphaned generated index, a redefined declared + index and the #3696 legacy-unique replacement are all still detected, still + categorised exactly as before, and still remediable through `os migrate`. + +- 193cd5c: fix(driver-sql): 空 `$and`/`$or`/`$not` 按布尔单位元编译 —— `$or: []` 不再返回全表 + + **这是一处查询行为变更,且直接关系到 RLS。** `{ $or: [] }` 以前返回**整张表**, + 现在返回**零行**。如果你的代码依赖了旧行为,它依赖的是一个 filter 旁路。 + + `applyFilterCondition` 把每个组合子都编译成一个 knex 分组回调,而 knex 对「一个子句 + 都没加进去的分组」不产出任何 SQL。于是「这个组是空的」和「这个组已被满足」编译成了 + 同一条查询。**丢弃子句不等于套用单位元**,而两个单位元的方向是相反的: + + | 写法 | 布尔代数 | 旧编译 | 错的方向 | + | -------------------- | ----------------------------- | --------- | ------------ | + | `{ $and: [] }` | TRUE → 全部行 | 全表 | 碰巧正确 | + | `{ $or: [] }` | FALSE → **零行** | 全表 | **静默放松** | + | `{ $or: [{a}, {}] }` | `{}` 是 TRUE 析取项 → 全部行 | `(a = ?)` | 静默收紧 | + | `{ $not: {} }` | `NOT TRUE ≡ FALSE` → **零行** | 全表 | **静默放松** | + + `$and: []` 恰好正确的理由不是代码理解了单位元,而是「丢掉」在 AND 侧碰巧等价于 + TRUE —— 同一段代码在 OR 与 NOT 侧就必然错。放松的那两格是安全相关的:`$or: []` + 最常见的来源正是「本该有条件、但循环一个析取项都没填进去」的 RLS read scope, + 把它当成全表意味着**本该看不到任何行的人拿到了整表**。 + + 同仓另外两个后端(`formula` 的 `matchesFilterCondition`、`driver-memory`)三条 + 本来就都是对的,`driver-sql` 是唯一的例外;现在四个答案统一。 + + **配套的形状拒收(否则修复会变得更糟)。** 套用单位元的前提是「编译成空」只剩一个 + 成因。在此之前 `$or: [null]`、`$or: ['x']`、`$or: [[…]]`、`$or: [new Date()]` + 同样会无痕消失;不先拦掉它们就上单位元,会把它们从「被静默忽略」**升级成「匹配所有 + 行」**,比原 bug 更坏。因此 `$and`/`$or` 的元素与 `$not` 的操作数现在必须是 + **plain object** 的 filter 节点,否则按 ADR-0112 响亮拒收 + (`INVALID_FILTER` / 400,报错指明出错位置,如 `filter.$or[1]`)。原型检查是关键 + 的一半:`Date`/`RegExp`/class 实例都满足 `typeof x === 'object'` 却枚举为空, + 若被接受就会被读成 TRUE。同理 `$and: 'x'` 这类非数组操作数也不再被当成一个名为 + `$and` 的字段列。 + + 判定是**结构性**的(编译前先归约整棵树),而不是「编译完再问 knex 有没有产出」—— + 原缺陷本身就是后者那种观察,而观察分不清「因为本来就是空」和「因为有东西没编译 + 出来」。结构判定没有这个盲区,并且保证编译器打开的每个分组都至少收到一条子句, + knex 再没有机会静默丢弃一个组。 + + 非空的 `$and`/`$or`/`$not` 编译方式完全未变。 + +- 5aae790: fix(driver-sql): `$not` 改为 NULL-safe —— 被比较列为 NULL 的行不再被否定条件静默排除 + + **这是一处可观察的查询行为变更,且直接关系到 RLS 的可见集合。** + `{ $not: { stage: 'won' } }` 以前**不返回** `stage IS NULL` 的行,现在**返回**它们。 + 如果你的规则依赖了旧行为,它依赖的是「同一条规则在不同后端给出不同可见集合」。 + + SQL 是三值逻辑:`NULL = 'won'` 是 UNKNOWN,`NOT UNKNOWN` 仍是 UNKNOWN,而 `WHERE` + 只保留 TRUE。于是 `applyFilterCondition` 编译出的裸 `NOT (stage = 'won')` 会把 + 「该列没有值」的行整批丢掉;同一条 filter 在 `driver-memory` 与 `formula` 的 + `matchesFilterCondition` 上是普通的两值 JS 求值(`undefined !== 'won'` → 行匹配), + 两边把这些行**都返回**。一个 spec 声明的算子,答案取决于跑它的是哪个驱动。 + + 这不是「数目对不上」而已:权限规则里的 CEL `!expr` 经 `cel-to-filter.ts` 正是降解成 + `{ $not: {…} }`,所以同一条 read scope 在 SQL 数据源与内存数据源上准入的行集不同。 + #5146 判定以 JS 家族的答案为准(2:1 的多数派;写 `!(stage == 'won')` 的人不会预期 + 「stage 为空的行被隐藏」),本次把 SQL 侧对齐过去。 + + **编译出来的形状。** `$not` 的操作数在取反之前先被改写成**全域(total)谓词** —— + 永远是 TRUE 或 FALSE,不会是 UNKNOWN: + + ```sql + -- 之前 + not (`stage` = 'won') + -- 现在 + not ((`stage` is not null) and (`stage` = 'won')) + ``` + + 对 issue 里给出的扁平形状,这与 `NOT (…) OR col IS NULL` 完全等价。把守卫下推到 + **每个叶子**而不是挂在 `NOT` 旁边,是为了在操作数嵌套时仍然正确:`$not` 里套一个 + `$or` 时,顶层的 `OR col IS NULL` 会把 JS 家族排除的行重新放进来(某一列为 NULL、 + 但另一个析取分支成立的行)。 + + **守卫方向按算子逐个判定,不是一刀切。** `{ $not: { a: { $ne: 5 } } }` 的语义是 + 「a 就是 5」,两个 JS 后端都把 NULL 行排除在外;无条件加 `OR a IS NULL` 会把这些行 + 交回去 —— 正是本驱动反复付过学费的静默放松(#2704 / #5134)。因此 + `$ne` / `$nin` / `$notContains` 用的是 `col IS NULL OR (…)`,`$eq` / `$in` / + `$gt` / `$contains` 一族用 `col IS NOT NULL AND (…)`,而 `$null` / `$exists` / + `$eq: null` / `$ne: null` 本来就是全域谓词,一个字节都不加。 + + **只有 `$not` 路径被改写。** 普通比较的 SQL 逐字符不变(`{ a: 1 }` 仍然是 + `a = 1`),因此没有任何非否定谓词因此失去索引;`$not` 路径上的 `IS NOT NULL` 守卫 + 本身处在一个原本就不可 sargable 的 `NOT (…)` 里。 + + `#5134` / PR #5243 定下的布尔单位元(`{ $not: {} }` → 零行、`$not` of FALSE → + 全部行、非 filter 节点的操作数按 ADR-0112 响亮拒收)全部保持不变;`{ field: {} }` + (#5240)也刻意不在此裁定 —— 它编译出的 SQL 与之前完全一致。 + + `driver-memory` 与 `formula` 无需改动,本次为三家各补了一组 pin 测试,把「值缺失 + 行在 `$not` 下的去留」钉在一起。跨驱动 conformance case(`FILTER_LOGIC_CASES`)与 + 契约 TSDoc 归 spec 车道,随 #5239 落地。 + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/plugins/driver-sql/package.json b/packages/plugins/driver-sql/package.json index cef800e02a..8b2f5ecec3 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.2", + "version": "17.0.0-rc.3", "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 f181150a35..d5e037c58b 100644 --- a/packages/plugins/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/plugins/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,61 @@ # @objectstack/driver-sqlite-wasm +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [2ddba89] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [f98fa65] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [193cd5c] +- Updated dependencies [5aae790] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/driver-sql@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/driver-sqlite-wasm/package.json b/packages/plugins/driver-sqlite-wasm/package.json index bdf70e8f19..8eec08b84e 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.2", + "version": "17.0.0-rc.3", "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 6da45ad4af..912a9e155a 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/embedder-openai +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 3c9dd915c7..e968605a01 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.2", + "version": "17.0.0-rc.3", "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 d92de2cd50..3cf55c19ff 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/knowledge-memory +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/service-knowledge@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index edd8c838f7..993e0f4f8d 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.2", + "version": "17.0.0-rc.3", "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 4a95c21038..5cdc5b7dc7 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/knowledge-ragflow +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/service-knowledge@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 5b1cca4a6c..f34ed58882 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.2", + "version": "17.0.0-rc.3", "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 0427d1b5e4..b9e708ebb7 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,144 @@ # @objectstack/plugin-approvals +## 17.0.0-rc.3 + +### Patch Changes + +- 7e5ac28: fix(approvals): 删除两处读 `session.roles` 的 admin 豁免 —— 记录锁与委托守卫回到单一权限词汇 (#4839) + + `plugin-approvals` 的 `lifecycle-hooks.ts` 里有两处 admin 豁免,都读 + `ctx.session.roles`:审批**记录锁**的 `bindApprovalLockHook`,以及 + `sys_approval_delegation` 的 `bindDelegationWriteGuard`。两处都已删除。 + + **这不是行为变更。** `session.roles` 在整个平台没有生产者 —— ObjectQL 的 + `buildSession()` 逐字段构造 session,从不写 `roles` —— 所以两个分支在任何真实引擎 + 路径上都是死代码,记录锁一直就对 admin 生效,委托一直就只能本人管理。删除让代码 + 说出运行时本来就在做的事(spec 的 `HookContext` 声明了 `roles`,消费方在读,生产方 + 从不写:典型的 declared ≠ enforced)。 + + **为什么不是「改用正确判据」而是删除。** `roles.includes('admin')` 还是第二套权限 + 方言:本仓库的权限一律由 ADR-0095 词汇裁决(能力授予 `permissions`、任职 + `positions`、由其派生的 posture),ADR-0090 D3 更是直接禁掉 `role` 这个拼法。同包的 + `ApprovalService.isOverrideActor` 已经这么做了。维护者裁定两处都取「删除」而非改判据: + + - **记录锁**:admin 释放锁定记录的正规路径已经存在(#3424 —— `recall` / + `decideNode` 驳回 / `reassign`,全部由 `isOverrideActor` 把关并留痕 + `via_override`)。让审批终结来释放锁,记录就永远不会在审批在途时被改写 —— 这正是 + 合规场景购买记录锁所要的保证。 + - **委托**:最终语义确定为**仅本人管理**(`delegator_id` 必须等于写入者;只有 system + 上下文旁路)。审批人临时不可用时,替他处置**在途**审批用的是 + `reassign`(把该审批人的名额交给替代人,连 per_group 分组归属一起带过去)/ + `recall` / 驳回。反过来,「替别人建一条委托」本来也做不到这件事:委托只在请求 + **开启**时(`resolveApproverSpec` 内的 `applyOooDelegation`)被查询,对已经挂在该 + 审批人名下的在途审批毫无作用。 + + 新增 `admin-exemption-retired.test.ts`,把上述证据变成可执行断言,并加了一道源码级 + pin:本包非测试源码中不得再出现 `roles` 标识符或与字符串 `'admin'` 的比较。 + + spec 侧 `session.roles` 的退役(至此零消费方)按 ADR-0049 enforce-or-remove 另立协议 + 单处理,不在本次改动内。 + +- 19e1a8f: fix(approvals): an approval decision can no longer strand a flow run silently when no automation engine is attached (#4420) + + #4420's fix closed every path by which a decision could be recorded while its + flow stayed parked — except one, and it is the one where none of the new guards + could run. Every guard it added (`assertRunResumable`'s pre-flight, the + `RESUME_TARGET_LOST` refusal, the `RESUME_FAILED` throw) hangs off the + automation engine. In a process where **no engine is attached**, all of them + were skipped by the same `typeof this.automation?.resume === 'function'` + condition that wrapped the resume itself — so the decision was written, the + mirrored status field advanced, and the call answered HTTP 200 with + `resumed: false` and **nothing logged at all**. That is #4420's reported + symptom exactly, reproduced in the one composition its fix could not see. + + The composition is reachable the same way the original bug was: a flow parks at + an `approval` node in a process that has the automation service, and the + decision arrives in one that does not (the plugin failed to init, or the host + was recomposed between releases). The request row still carries a + `flow_run_id` — which is the row's own declaration that a run is parked on this + decision. + + **What changes.** The decision still stands. Rolling it back is not on the + table (a human really decided, and the row is durable by then), and refusing + every such call would break the standalone approvals compositions the + pre-flight deliberately protects — so `finalized` and `resumed` are unchanged + for every existing caller. What changes is that the gap is no longer silent: + + - it is logged at **`error`**, per the durability rule in `AGENTS.md` — + persisted state and runtime state disagree while nothing looks broken from + the outside, which is the class that rule exists for; + - the response carries **`resumeError`**, so `resumed: false` arrives with its + reason and the stranded run's id instead of leaving the caller to guess + whether a resume was even attempted. + + It reuses the already-registered `RESUME_FAILED` code and the existing resume + message shape rather than introducing a new vocabulary — the fact being + reported (an outcome recorded whose run did not advance) is the same one. + + Applied at all five sites that resume a recorded outcome: `decide`, the + revision-limit auto-rejection, `sendBack`, `resubmit`, and both branches of + `recall` (whose revise-window path needs `cancelRun` rather than `resume`). + + A request that names **no** run is unaffected and stays quiet — there is + nothing parked on it, and reporting one there would be the mirror-image + failure that trains operators to skim `error`. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index f493c9d27b..6da40a4e97 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.2", + "version": "17.0.0-rc.3", "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 6148094880..e60d7a703f 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,167 @@ # @objectstack/plugin-audit +## 17.0.0-rc.3 + +### Patch Changes + +- c5e7bd9: fix(plugin-audit): say where the audit system tables were provisioned, and stop skipping provisioning silently (#4887) + + `AuditPlugin.provisionSystemTables()` created `sys_audit_log` / `sys_activity` / + `sys_comment` at `kernel:ready` and then said **nothing** — not on success, and + not when it skipped the work entirely (`typeof engine.syncObjectSchema !== +'function'` returned silently). `syncObjectSchema()` itself returns `void` and + has three silent exits of its own — the object is not in the registry, no driver + resolves for it, or the resolved driver has no `syncSchema` — none of which + throw. So "provisioned three tables" and "provisioned nothing at all" produced + byte-identical logs, and the only way to tell them apart was to go looking in a + database. + + #4887 is what that costs. `sys_audit_log` and `sys_activity` were reported as + never provisioned because they were absent from the primary SQLite file, with + the silent `typeof` bail named as the likely cause. Neither was true: + `sys_audit_log` (`lifecycle.class: 'audit'`) and `sys_activity` + (`lifecycle.class: 'telemetry'`) are routed by **ADR-0057 §3.6** to the + dedicated `telemetry` datasource whenever one is registered, and `os dev` + registers one by default as a _sibling file_ (`dev.db` → `dev.telemetry.db`). + Both tables had been created — in the other store. `sys_comment` carries no + lifecycle class, stays on the primary, and was the one that "existed". Nothing + in the log connected those three facts. + + Provisioning now reports itself: + + - **Wholesale skip is a `warn`, naming the consequence** — the tables stay + lazy-created on first WRITE, so an env that READS one first (the home page + activity feed queries `sys_activity` before any mutation) logs "no such + table" until something writes. + - **One `info` line per boot listing where each table landed** — + `sys_audit_log→telemetry, sys_activity→telemetry, sys_comment→sqlite`, + resolved through the engine's own `getDriverForObject`, so the log states the + routing rather than leaving it to be inferred. + - **A second `info` line when the ADR-0057 split is in effect**, saying + explicitly that those tables live in a different store — on SQLite, a + different _file_ — and that anything reading them without naming the object + (raw SQL against the default datasource) will report "no such table" even + though provisioning succeeded. + - **An object that resolves to no driver is a `warn`** — `syncObjectSchema()` + returns without issuing any DDL in that case and throws nothing, so the + per-object `catch` never fires; from outside the engine this is the only place + it can be observed. + + Behaviour is otherwise unchanged: the same three objects are synced, per-object + failures stay isolated, and an engine without on-demand DDL still degrades + instead of failing `start()`. + +- 0162c81: fix(plugin-audit): stop mirroring `sys_job_queue` traffic into the audit ledger (#5193) + + `SKIP_OBJECTS` in `audit-writers.ts` excludes operational telemetry / plumbing + from `sys_audit_log` and `sys_activity` — ADR-0057 decision 5, _"stop the + amplifier"_. Its group (2) already listed `sys_job`, `sys_job_run` and + `sys_automation_run`; `sys_job_queue` — the highest-volume table of that same + family — was the one sibling missing, so every durable queue message was + mirrored into both sinks. + + The audit hooks register for **all** objects (`afterInsert` / `afterUpdate` / + `afterDelete`) and there is no "writes made under a system context are not + audited" exemption, so `DbQueueAdapter`'s own writes were recorded like user + edits. One message costs at least three of them — the publish insert, the lease + `pending → running` update and the terminal `→ completed` update, plus one retry + update per failure and the reaper's periodic DELETE of completed rows — each + producing an `sys_audit_log` **and** an `sys_activity` row. Since queue-backed + email delivery landed, that ran on every single mail. Each `beforeUpdate` also + paid an extra `findOne` snapshot of the row it was about to change. + + `sys_job_queue` is engine-owned plumbing (`managedBy: 'engine-owned'`, + `enable.apiMethods: ['get', 'list']`, `lifecycle.class: 'transient'`) that no + user can write, so those rows carried no compliance value — only noise and write + amplification. Nothing else changes: the exemption is one name in one list, and + ordinary business objects are audited exactly as before. + + Operators who charted queue throughput off `sys_activity` should read + `sys_job_queue` directly instead — it is the system of record for queue state, + and unlike the audit sinks it is exposed for reading (`get` / `list`). + +- 055f0c9: fix(plugin-audit): stop mirroring chunked-upload progress into the audit ledger (#5202) + + `SKIP_OBJECTS` in `audit-writers.ts` excludes operational telemetry / plumbing + from `sys_audit_log` and `sys_activity` — ADR-0057 decision 5, _"stop the + amplifier"_. `sys_upload_session` was the second table missing from group (2) + for the same reason `sys_job_queue` was (#5193): it declares + `lifecycle.class: 'transient'` and its own object comment says what the rows are + worth — _"an upload session is ephemeral state, never business truth"_ + (ADR-0057 / #2970 item 4) — but nothing connected that declaration to the + exemption list, which is hand-written. + + The audit hooks register for **all** objects and there is no "writes made under + a system context are not audited" exemption, so `StorageMetadataStore`'s own + writes were recorded like user edits. A chunked upload of N parts costs 1 + N + writes — the `createSession()` insert plus one `updateSession()` per chunk — and + then a terminal status update and the row's removal, each producing an + `sys_audit_log` **and** an `sys_activity` row: 2 × (1 + N) rows for one file, + with a `beforeUpdate` snapshot read apiece. Each of those rows was also unusually + fat, because `updateSession()` writes the merged **full** record, so the `parts` + JSON blob that grows with every chunk rode along in each diff's `old_value` / + `new_value`. + + Nothing else changes: the exemption is one name in one list, and ordinary + business objects are audited exactly as before. In particular `sys_file` stays + audited — it declares `transient` too, but only to reap tombstones and + unfinished uploads; its rows are mostly permanent business truth and keep their + compliance value. + + Operators who tracked upload activity through `sys_activity` should read + `sys_upload_session` (in-progress state) and `sys_file` (the durable record of + what was actually stored) instead. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 4aca401049..831af33038 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.2", + "version": "17.0.0-rc.3", "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 c53632a4fe..a0e62be03e 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,471 @@ # Changelog +## 17.0.0-rc.3 + +### Major Changes + +- de113a4: BREAKING(auth): `organization/create` 改判**实际生效的** tenancy posture —— 没有组织墙的部署不再能创建组织 (#5261) + + `POST /api/v1/auth/organization/create` 的闸门此前判的是操作者**请求的** posture + (`postureEnforcesWall(resolveTenancyPosture())`,一次纯 env 读)。现在判 `tenancy` 服务给出的 + **生效** posture —— `tenancy?.posture ?? resolveTenancyPosture()`,与 `/auth/config` 的 + `features.multiOrgEnabled` 是**同一次求值**。 + + ## 为什么 + + 两个站点此前只在一种形状下分叉,而那种形状恰恰是最不该放行的一种 —— ADR-0093 D5 **降级态**: + 请求了 `isolated`/`group`,但企业包 `@objectstack/organizations` 缺席,于是 `tenancy.posture` + 解析为 `single` 且 `degraded=true`。此时: + + - 闸门读「请求」→ **放行**; + - `/auth/config` 读「生效」→ `multiOrgEnabled=false`,console 把「创建组织」入口**藏起来**。 + + 结果是 UI 没有按钮而 API 打得通,并且建出来的每一个组织都是**没有任何引擎强制的租户边界** —— + 声明了但没强制,ADR-0049 最讨厌的那一类,只不过发生在部署层。改判生效 posture 之后两者同解、 + 永不分叉:**没有墙,就没有组织**,无论这个部署是从未要过墙,还是要了没拿到。 + + ## 破坏性影响(有意为之) + + **没有安装企业包 `@objectstack/organizations` 的部署将完全无法创建组织**,任何 env 组合都不行 —— + `OS_TENANCY_POSTURE=isolated`、`OS_MULTI_ORG_ENABLED=true`、两个一起设,都不再能把闸门说通。 + 这是一次实打实的能力收缩,不是 knob 纠正,所以搭 v17 主版本车。 + + | 部署形状 | 改前 | 改后 | + | ------------------------------------------------- | ------ | ------------- | + | 有企业包,posture `isolated` / `group`(墙真的立着) | 200 | **200**(不变) | + | **请求了墙但企业包缺席(D5 降级态)** | 200 | **403** ⚠️ | + | `single` / 两个 knob 都不设 | 403 | 403(不变) | + | 未注册 `tenancy` 服务的精简嵌入(回落 env 解析) | 按 env | 按 env(不变) | + + `serve.ts` 本来就在降级态**默认拒绝启动**(要 `OS_ALLOW_DEGRADED_TENANCY=1` 才走),所以这条收缩 + 命中的是一个已经需要显式选择才能到达的形状:从此那里的 org-create 路由也一并拒绝,而不是半通不通。 + cloud 控制面与任何装了企业包的部署不受影响。 + + **迁移**:需要多组织能力的部署安装并声明 `@objectstack/organizations`(ADR-0081 D2)。仅靠 env + 声明一个墙、而没有实现它的运行时,不再被当作多组织部署对待。 + + ## `@objectstack/verify`(minor,新增) + + `BootOptions.multiTenant` 增加 `'posture-only'` 取值:注册一个内置的 `org-scoping` 服务替身, + 让 `tenancy` 服务解析出真实、**非降级**的 `isolated` posture,从而打开受 posture 把守的路由 —— + 供那些「组织墙是**前置条件**而非被测对象」的 fixture 使用(#3624 的 `org-create-default-team` + dogfood 就是为它而建:那条回归此前靠「boot 后翻 env、闸门 live 读」开路,本次收缩把这个绕法关死了)。 + + ⛔ 它**不做任何租户隔离**:不 stamp `organization_id`,不 scope 任何查询 —— 它让部署的 + **posture** 为真,不是让**墙**为真。跨租户隔离的唯一诚实证明仍然是 `multiTenant: true` + + 真实的企业包,这也是那些 gate 在本仓继续 skip 而不是假装通过的原因。 + +### Minor Changes + +- 7cf1531: fix(auth): an unrecognised membership policy is refused by both reconcilers, not auto-bound by one of them (#5205) + + **The sign-up path used to bind anyway.** `reconcileMembership` and + `backfillMemberships` — both public exports of `@objectstack/plugin-auth` — read + the same `policy` field and judged it with opposite predicates. Sign-up tested + `policy === 'invite-only'`, so any _other_ value fell through to the `auto` + branch and auto-bound the new user; the backfill tested `policy !== 'auto'` and + refused. One input, two opposite postures, and the fail-open half was the one + that runs per sign-up. A caller who wrote `'inviteOnly'` — or any host passing + the policy from JavaScript, past the `MembershipPolicy` type — got auto-binding + while believing they had switched it off, with nothing in the logs to say so. + + Both entry points now check `isMembershipPolicy()` before any policy semantics + and refuse: nothing is bound, and the refusal names the offending value at + `error` level (and on the returned result, so it survives a caller that passed + no logger). This is the posture #5152 took one layer up at the settings + boundary — an unrecognised value is rejected loudly, never coerced to `auto`. + + **Contract change — `ReconcileOutcome` gains `'invalid-policy'`, and + `BackfillMembershipsResult.reason` gains the same member.** Both are exported + types, so a consumer that switches exhaustively over them (a `never`-checked + `default`, or a `Record< ReconcileOutcome, … >`) must handle the new member. + The new verdict is deliberately _not_ a reuse of the existing `policy-skip` / + `'policy'`: those mean "a valid policy said no", and reporting them for "this + is not a policy" sends whoever is debugging a missing bind to inspect a + deployment setting that is fine. `BackfillMembershipsResult` also gains an + optional `error?: string`, and the `logger` shape on `ReconcileMembershipDeps` + gains an optional `error?` method (it falls back to `warn`). + + **No behaviour change for the two real policies.** `auto` binds and + `invite-only` skips exactly as before, on both paths — the framework's own + callers resolve the policy through `AuthManager.getMembershipPolicy()`, whose + return type is `MembershipPolicy`, so nothing on a supported path can reach the + new branch. This closes the dormant divergence on the export surface. + +- 586d6f7: feat(auth): `membership_policy` is a platform setting, and sign-up and backfill read one source (#5152) + + **What a new user joins is now configurable at runtime.** ADR-0093's + `membershipPolicy` decides whether a freshly created user is auto-bound to the + deployment's default organization (`auto`) or gets membership only from an + explicit act — creating a workspace, accepting an invitation, an admin adding + them, SSO just-in-time provisioning (`invite-only`). Until now it was settable + **only** as an `AuthPlugin` constructor option, and the AuthPlugin a self-hosted + stack gets is injected by the CLI, which passes no such option and has no env + fallback. Every self-hosted deployment therefore ran `auto`, with no way to say + otherwise. `invite-only` was, in practice, unreachable outside a custom host. + + It is now `auth.membership_policy` in the platform settings — a two-value select + (`auto` / `invite-only`, default `auto`) alongside `signup_enabled`, which it + pairs with: one says whether people may self-register, the other says what they + join when they do. Set it in Setup → Authentication → Membership, or pin it + per-deployment with `OS_AUTH_MEMBERSHIP_POLICY`. It applies **without a + restart** — the existing `settings.subscribe('auth', …)` re-application seam + carries it, the same one the password-policy keys ride. + + **No behaviour changes unless you set it.** Only an _explicit_ value applies; + the manifest's `auto` default is a UI default and never masks a deployment that + configured the policy in code. A stack that sets nothing keeps today's + auto-binding exactly. + + **Bug fix — the two membership paths read one source.** Sign-up (the reconciler + in better-auth's `user.create.after`) read the AuthManager's live config, while + the ADR-0093 D6 backfill of pre-existing member-less users read the plugin's + **constructor options**. Wiring a setting to the first and not the second would + have produced "sign-up honours the new policy, backfill still runs the old one" + — and the backfill binds in **bulk**, so it is the more dangerous half. Both now + resolve the policy through the new `AuthManager.getMembershipPolicy()`, and the + backfill waits for the settings namespace to bind before its first pass (the two + `kernel:ready` hooks fire in registration order, which was the wrong order). + + **An invalid value is rejected, not coerced.** `PUT /api/settings/auth` refuses + a policy outside the declared option table (`invalid_option`, naming the allowed + set). A value arriving from `OS_AUTH_MEMBERSHIP_POLICY` — which bypasses that + validation — is logged at `error` and **ignored**, leaving the deployment's + current policy in force; it is never silently read as `auto`, because that would + leave an operator believing a wall is up while every sign-up is auto-bound. + + New public API on `@objectstack/plugin-auth`: `AuthManager.getMembershipPolicy()`, + plus `MEMBERSHIP_POLICIES` and `isMembershipPolicy()` from `reconcile-membership`. + +### Patch Changes + +- 08f93bc: fix(auth): `organization/create` gates on the authoritative `OS_TENANCY_POSTURE`, not the demoted `OS_MULTI_ORG_ENABLED` (#5233) + + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — mounted the entire organization wall and still + answered `403 Creating additional organizations is disabled on this deployment.` + to `POST /api/v1/auth/organization/create`. Org-less users had no way to create + their workspace, so the guided "Create your workspace" path was a dead end. + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + Two sites in `AuthManager` kept reading the demoted boolean directly, so both + reported "single-org" on a deployment that had asked for a wall and got one: + + - `organizationHooks.beforeCreateOrganization` — the 403 above. It now judges + `postureEnforcesWall(resolveTenancyPosture())`, matching the knob `serve.ts`'s + own ADR-0093 D5 boot guard keys on. Intent is unchanged (single-org still + refuses); only the knob is corrected. + - `/auth/config`'s `features.multiOrgEnabled` — its no-tenancy-service fallback + read the same boolean. It now falls back to the resolved posture, so a lean + embedding advertises the capability its own gate allows. + + **No configuration change is needed anywhere.** Deployments that set only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + workaround people used to unblock themselves stays valid. Deployments that set + only `OS_TENANCY_POSTURE` can now drop the redundant boolean. + + `resolveMultiOrgEnabled()`'s doc comment in `@objectstack/types` — which still + instructed "the auth manager's `/auth/config` feature flag and org-create guard + … MUST call this", written before the demotion — now says the opposite: ask the + posture, and never gate on this boolean. Its behaviour is unchanged. + +- 55dbbba: feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937) + + Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec` + declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any + of them, so an author wrote a budget, it parsed, and nothing happened (#4686). + `@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense, + that the dispatcher called it and short-circuited with 429 — it had **zero call sites** + outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to + tune did not exist (#4937). Neither half was broken; they were simply never connected, and + both were documented as if they were. + + They are connected now, along one narrow path. + + ## What you write + + ```ts + export default defineStack({ + manifest: { + /* … */ + }, + server: { + security: { + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 }, + }, + trustProxy: false, + }, + }); + ``` + + `server:` is a **new** top-level stack key. Nothing declared it before, so no existing + stack changes behaviour on upgrade — there is no configuration that was inert yesterday + and starts throttling today. + + It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and + nothing else, because those are the two keys with a consumer. It is NOT the nine-key + `HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and + mounting them here would have made seven dead keys writable in one move (their + enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled + budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0` + is refused at `defineStack` rather than at 3am. + + **No `server.port`.** The listening socket belongs to the deployment, not the artifact, and + `objectstack serve -p` already owns it. The precedence rule is recorded in the schema and + the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` > + built-in default.** + + ## What happens + + Every inbound request the server routes — REST, dispatcher, service routes, anything + mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`, + refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429** + with a `Retry-After` computed from the bucket itself and the standard error envelope + (`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered. + + The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for + anonymous traffic — so one abusive session cannot spend another user's budget, and + credential-stuffing traffic (which has no principal yet) is still metered per source. That + IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**; + otherwise it is the transport's own peer address. Undeclared, those headers are attacker + input: honouring them by default would hand anyone an unlimited supply of fresh buckets and + let them drain a chosen victim's. + + Counters live in the kernel `cache` service when one is registered, so a multi-node + deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at + consume time so a cache plugin that registers later is still picked up (#4772). With no + cache service at all it falls back to a per-process store and says so once, naming the + consequence: the effective limit becomes the declared budget multiplied by the number of + nodes, and nothing about the deployment looks wrong. + + ## Also in this change + + - **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation + passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered + middleware could not read the request, write a response, or decline to continue — a + declared seam with no execution behind it, unnoticed because nothing called it. It now + delivers method/path/query/headers plus the transport peer address + (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be + registered before the routes it guards; the kernel's two-phase boot makes that automatic + (`init()` before every `start()`). + - **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it + does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous + in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is + now labelled as the reference material it always was rather than as live defaults. + + ## Explicitly NOT wired + + `ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain + **known-unwired**. Declaring them still changes nothing. They are not retired here either: + the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one + key of a surface that may yet be implemented would only have to be undone. Tracked, not + silent. + +- 9fa6bab: fix(plugin-auth): sign JWTs with an algorithm the host can actually use (#3585) + + On any host whose WebCrypto lacks Ed25519 — StackBlitz/WebContainer is the + reported one — **every authenticated request 500'd as soon as the OIDC provider + was enabled**, which is the default whenever the MCP server is on. Sign-in + succeeded, then the first `/api/v1/auth/get-session` returned 500 with + `OperationError … cfrgGenerateKey`. An app that never asked for OIDC got an + unusable login, and the only escape was `OS_OIDC_PROVIDER_ENABLED=false`. + + The cause was an inherited default: `plugin-auth` registered better-auth's `jwt` + plugin without `jwks.keyPairConfig`, so better-auth's **EdDSA / Ed25519** default + applied and jose asked WebCrypto for an algorithm the host does not have. It hit + ordinary cookie login rather than just OAuth clients because the plugin's `after` + hook signs a `set-auth-jwt` header for _every_ session. + + **Three changes, no configuration required:** + + - **The signing algorithm is now chosen by capability, not by inheritance.** At + instance build the plugin asks WebCrypto whether it can generate an Ed25519 + key pair — using the exact algorithm descriptor jose uses — and pins + `keyPairConfig` to `EdDSA`/`Ed25519` when it can, or falls back to **ES256** + when it cannot. Hosts with Ed25519 behave exactly as before. + - **Deployments that already minted an EdDSA key keep working.** Choosing ES256 + for _new_ keys is not sufficient on its own: better-auth's `resolveSigningKey` + falls back to _any_ stored key when none matches the configured algorithm, so + an existing EdDSA key in `sys_jwks` would still be selected and then fail in + `importJWK`. On a host without Ed25519 the plugin now installs better-auth's + `adapter.getJwks` keyring seam and hides keys this host cannot import, so a + fresh ES256 key is minted and the deployment converges on a working state. + Hidden rows are **never deleted** — move back to a host with Ed25519 and they + are used again. Such a host also stops advertising those keys in + `/api/v1/auth/jwks`, since it can neither sign nor verify with them. + - **A signing failure can no longer take down the session path.** If signing + fails anyway (neither algorithm usable, an unwritable `sys_jwks`, or a rotated + `OS_AUTH_SECRET` that cannot decrypt the stored key), `/get-session` now + returns the session normally and simply omits the `set-auth-jwt` header, + instead of 500ing. The failure is reported once with an error that names the + algorithm, says what still works, and points at the opt-out — and is queryable + via `getDegradedAuthFeatures()` under the new `jwtSigning` key. + + No configuration changes and no migration. Deployments on hosts with Ed25519 are + unaffected: the keyring override is installed only where it is needed. + +- 4addd9d: feat(driver-sql)!: organization-scoped uniques are NULL-safe — `COALESCE(organization_id, '__global__')` key part + `unique: 'organization'` on declared indexes (ADR-0120 D3/D4, #5030) + + SQL UNIQUE is NULL-distinct, so the `(organization_id, field)` composite #3696 + introduced enforced **nothing** on rows whose organization is NULL — which on a + single-tenant stack (where the kernel injects the column and never fills it) is + **every row**: field-level `unique: true` was a silent no-op there, measured in + #5030. Per ADR-0120 D3, every organization-scoped unique now materializes its + organization key part as `COALESCE(organization_id, '__global__')`: NULL-organization + rows collapse into one platform bucket, unique among themselves; non-NULL rows + are untouched. Storage stays NULL — the sentinel exists only inside the index + key, and it is the same word the autonumber sequence table already uses + (`GLOBAL_TENANT`), so a constraint-violation error reads as "the platform + bucket collided", not as corrupt data. + + What changes, concretely: + + - **Field-level `unique: true`** (and the new explicit synonym + `'organization'`) on a tenant-scoped object → composite + `(COALESCE(tenantField, '__global__'), field)`. `unique: 'global'` and + tenant-less objects are unchanged. + - **Declared indexes gain the ADR-0120 D1 scope vocabulary at the driver**: + `unique: 'organization'` prepends the NULL-safe organization key part to the + listed columns (degrading to the listed columns on a tenant-less object; a + listed tenant column is made NULL-safe in place instead — the S6 respelling). + `unique: true` / `'global'` on a declared index stays **verbatim** — the + #3696 contract, now the `'global'` arm; the nine engine dedup/idempotency + keys keep their exact physical shape. (The spec/lint side of the vocabulary + lands separately via #4986; the driver deliberately merges first.) + - **Drift detection reads both sides through one normalization** + (the #4884 discipline, extended to the tenant key part): the physical + `COALESCE(organization_id, )` form is attributed to the column, + compared **literal-agnostically**, and recognised as the sync's own + vocabulary — a healthy database reports zero drift on every dialect. + - **Existing bare composites migrate through the ceremony (ADR-0120 D4)**: + `(organization_id, X) → (COALESCE(organization_id, '__global__'), X)` + surfaces as a `recreate_index` drift op — a pure tightening — gated by a + **duplicate pre-flight probe**. Clean probe → the op grades `safe` and dev + `autoMigrate: 'safe'` / a plain `os migrate apply` applies it. Duplicates + (data the void constraint wrongly admitted) → the op is **blocked** with a + per-group row report, the old index stays in place, and apply re-probes so + even `--allow-destructive` cannot drop a constraint whose replacement is not + creatable. Deduplicate, re-plan, apply. + - **`'__global__'` is reserved at the organization-minting seam** + (plugin-auth): an organization whose id or slug equals the sentinel is + rejected at creation with a prescriptive error (ADR-0120 D3 guardrail). + + Migration note for operators: on databases with pre-existing + organization-composite uniques, the first `os migrate plan` after upgrading + shows one `recreate_index` per affected index. On healthy data it auto-applies + in dev and is a no-op content-wise; a blocked op means the #5030 defect + admitted real duplicate rows — resolve the listed rows first. MySQL < 8.0.13 / + MariaDB cannot express the functional key part: the driver degrades to the + bare composite, says exactly what is not enforced at `error` level, and keeps + reporting the tightening as drift for after the server upgrade. + +- b40f81c: docs(plugin-auth): the session of record is always `sys_session` — cache backs rate-limit counters only (#4785) + + Settles an architectural question that had been answered two different ways by + the code and the docs. **Nothing about the runtime changes**: this records the + decision, proves the behaviour that depends on it, and corrects the docs that + described the road not taken. + + **The decision.** ObjectStack's session of record is always the `sys_session` + table. The kernel `cache` service serves authentication as the ADR-0069 D2 + rate-limit counter store and nothing else. It is never bound as better-auth's + `secondaryStorage`, because that option is not a counter store — handing + better-auth one also relocates sessions into it (`createSession` skips the + `sys_session` row; `findSession` answers from the cached snapshot without + reading the database). ADR-0069 D4's three session controls — idle timeout, + absolute lifetime, concurrent-session cap — all revoke by writing that row, so a + cache-backed session store would silently disable every one of them. Dual-writing + (`session.storeSessionInDatabase: true`) was considered and rejected as the worst + of the options: the row exists, so the controls _appear_ to work, while the read + path still answers from the cache. + + **Why this needed settling rather than just fixing.** The conflict had never + fired — the cache lookup that would have wired `secondaryStorage` ran before the + cache service registered, so the binding never took in a standard composition. + The declaration and the runtime disagreed for a month and no test could tell, + because no test asserted that a D4 control ends a _live session_; they asserted + at most that a row got stamped. A stamped row nobody reads is exactly the failure + mode in question. + + **What is new.** `session-of-record.test.ts` drives the real better-auth pipeline + end to end and proves each of the three D4 controls actually de-authenticates a + live session cookie — not that a column was written. It also pins the + counter-factual: with a `secondaryStorage` bound, `sys_session` stays empty and + the idle timeout never fires. Two facts that make the guarantee hold for real + deployments are pinned with it — `AuthManager` does not plumb + `storeSessionInDatabase`, so the rejected dual-write shape is unreachable through + configuration; and the default composition (OIDC provider on) makes better-auth + _refuse to boot_ with a `secondaryStorage` rather than degrade quietly. + + **For hosts.** `cacheSecondaryStorage()` remains exported for anyone who wants + better-auth's cached session store deliberately. It now says plainly what it + costs: opting in disables the ADR-0069 D4 session controls, and a revoked session + stays usable until its cached copy expires. Moving sessions into the cache + platform-wide would be a new decision requiring its own revocation-consistency + requirements, not a configuration change. + + ADR-0069's D2 "shared store" is scoped to rate-limit counters, D4 records + `sys_session` as a precondition rather than a deployment preference, and the + `ICacheService` contract page no longer lists session storage among the cache's + uses. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/rest@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 95b520d392..8f7ab80b8a 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.2", + "version": "17.0.0-rc.3", "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 e3d756bbee..7ca9caf6f9 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,152 @@ # @objectstack/plugin-dev +## 17.0.0-rc.3 + +### Patch Changes + +- 2ddba89: fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262) + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — therefore reads `false` from + `resolveMultiOrgEnabled()` while running a fully mounted organization wall. + #5233 corrected two sites in `plugin-auth`; a census found eight more, all + written before that function's doc comment was corrected. Third recurrence of + the shape (cloud#1020, #5233). + + Each site was judged separately for **which** posture answers its question — + what the operator REQUESTED, or what the `tenancy` service reports is actually + IN FORCE — rather than converted mechanically: + + - `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the + REQUESTED posture (it is constructed below the kernel, with no service + registry to ask). The `organization_id` column was always provisioned; what + diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's + hottest predicate unindexed while SecurityPlugin compiled that same wall. + - `plugin-dev` — whether to load the enterprise `@objectstack/organizations`. + REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall, + so asking whether the wall is up would be circular. A posture-only dev stack + previously never loaded the package at all and served traffic unwalled. Its + diagnostic now names the posture that was requested instead of asserting + `OS_MULTI_ORG_ENABLED=true` at an operator who never set it. + - `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture, + via the `tenancy` service. These ask "will the per-org replay run instead of + me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so + keying on the request would defer to a replay that can never happen. Walled + deployments previously inline-seeded exactly the NULL-organization rows the + code's own comment exists to avoid. + - `cloud-connection` marketplace local install (install-time seed + rehydrate + heal) — EFFECTIVE posture, same reasoning. The install path is a write path: + a walled deployment wrote every sample row with no `organization_id`, landing + the app's data outside the wall its own reads apply. + - `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no + kernel to ask, and a suppressed warning is the costlier error for a + diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a + process-level fact into a per-instance verdict on whichever write landed + first. The gate now resolves live, which is affordable because + `auditMissingTenant` consults it only after the `tenantId` early-out. + - `cli` `os verify` — REQUESTED posture. This one produced a green verification + run over an unverified property: a posture-only deployment silently skipped + every multi-tenant proof and exited 0. + + **No configuration change is needed anywhere.** Deployments setting only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + belt-and-braces configuration stays valid. Deployments that set only + `OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is + unchanged at every site; only the knob each one reads is corrected. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [7cf1531] +- Updated dependencies [586d6f7] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [2b63a00] +- Updated dependencies [18b8eaa] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [5d3ced9] +- Updated dependencies [9fa6bab] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [7c2f7dd] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [de113a4] +- Updated dependencies [caf144a] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [b40f81c] +- Updated dependencies [db2ea82] +- Updated dependencies [f61c8cf] +- Updated dependencies [718b229] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [69a89ce] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/plugin-hono-server@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/plugin-auth@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/rest@17.0.0-rc.3 + - @objectstack/plugin-security@17.0.0-rc.3 + - @objectstack/service-storage@17.0.0-rc.3 + - @objectstack/account@17.0.0-rc.3 + - @objectstack/setup@17.0.0-rc.3 + - @objectstack/driver-memory@17.0.0-rc.3 + - @objectstack/service-i18n@17.0.0-rc.3 + - @objectstack/service-realtime@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index c249096be6..e8c8e81089 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.2", + "version": "17.0.0-rc.3", "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 73487a5ef5..4bc8faf4b6 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,379 @@ # @objectstack/plugin-email +## 17.0.0-rc.3 + +### Minor Changes + +- bcfebb0: fix(cli,plugin-email)!: `OS_EMAIL_PROVIDER=resend/postmark` without an API key now fails the boot instead of silently becoming the log transport (#5132) + + **BREAKING for one configuration: a delivery provider selected without the + credential it needs.** `os serve` used to answer that by rewriting `provider` to + `log`, printing a warning, and booting normally. The result was a server that + accepted every send, recorded each one in `sys_email` as sent, and delivered + nothing — the warning scrolled past in CI logs and the truth surfaced when a + user reported never receiving a verification code. #5087 closed exactly this gap + inside `@objectstack/plugin-email` (`makeTransport` throws rather than + substituting a transport); the CLI's own capability assembly kept doing it one + layer up, for `resend` / `postmark`. + + `resolveEmailCapabilityArg` now refuses every mail configuration it cannot + deliver through, the way its neighbouring `smtp` arm already did: + + - `resend` / `postmark` with no `OS_EMAIL_API_KEY` (or `config.email.apiKey`); + - a `provider` tag outside `log` / `smtp` / `resend` / `postmark` — including + the retired `sendgrid` / `ses`, which get their SMTP migration in the message. + + **Who is affected:** deployments (typically CI or preview environments) that set + `OS_EMAIL_PROVIDER=resend` or `=postmark` without a key and relied on the + fallback to boot. Nothing else changes — a complete configuration is passed + through untouched, and an unset `OS_EMAIL_PROVIDER` still defaults to `log`. + + **Migration — one line, either direction:** + + - the environment is _not_ meant to send mail → `OS_EMAIL_PROVIDER=log` + (that explicit value is the supported way to say so, and why refusing the + others is fair); + - the environment _is_ meant to send mail → set `OS_EMAIL_API_KEY` (or + `config.email.apiKey`). + + Both errors name the consequence and both fixes, per AGENTS.md's + degradation-log-level rule. + + `@objectstack/plugin-email` gains the vocabulary the CLI reads instead of + restating: `API_KEY_EMAIL_PROVIDERS`, `emailProviderRequiresApiKey()` and the + `ApiKeyEmailProvider` type, alongside `EMAIL_TRANSPORT_PROVIDERS` / + `isEmailTransportProvider` / `unsupportedProviderFix` from #5094. One vocabulary, + two consumers, pinned by a contract test — a second literal list in the CLI is + how the settings dropdown and the transports drifted apart in the first place. + +- 9c4f174: feat(plugin-email): durable email delivery through `sys_job_queue`, opt-in (#5160) + + `IEmailService.send()` has always delivered **inline**: the SMTP session ran + inside the caller's `await`, and `EmailService`'s retry loop lived in the same + process — so a crash between the attempt and the retry dropped the message with + no trace beyond a `sys_email` row stuck at `queued`. The pieces for a durable + path all existed (`sys_job_queue`, the `DbQueueAdapter`, an `email.send.async` + subscriber) but nothing in the repo ever published to that topic. + + **New: `queueDelivery`.** With it on, `send()` persists the `sys_email` row, + publishes an `email.send.async` job **referencing that row**, and returns + `{ status: 'queued' }` immediately. A worker delivers the row and finalizes it + in place (`sent` + `message_id`, or `failed` + `error`); the queue retries with + exponential backoff (1s → 5min cap) and dead-letters the job when the attempts + run out, so a restart resumes delivery instead of losing it. The `'queued'` + status was already in `EmailDeliveryStatus` — no spec change. + + Three ways to turn it on, all default-off: + + - `new EmailServicePlugin({ queueDelivery: true })` + - `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) on `os serve` + - Settings → Mail → **Durable queue delivery**, hot-applied without a restart + + **One retry budget, not two.** `retries` keeps its meaning — total attempts are + `retries + 1` in both modes. Inline it drives the in-process loop; queued it + becomes the queue's `maxAttempts` and the per-row loop is pinned to one attempt + per delivery. Turning the toggle on changes _where_ a retry happens (durable, + backed off) and never _how many_ happen, so the two layers cannot multiply. + + **Fixed in the same change: the `email.send.async` subscriber inserted a new + `sys_email` row per delivery.** It called `send()` with the message, so a job + the queue retried five times left five rows — four permanently `failed`, none + carrying the real attempt count. It now delivers the referenced row via + `deliverPersistedRow`, so one message is one row and `attempt_count` + accumulates on it. Messages published in the old shape (a bare `SendEmailInput`) + are still accepted and delivered inline for a migration window. + + Boundaries worth knowing before you switch it on: + + - **"Send test email" always sends inline**, in every mode — the button has to + report the provider's own answer (`535 …`), and "queued" is exactly the + non-answer #5087 removed from it. + - **Messages with attachments or custom headers are delivered inline**, because + `sys_email` has no columns for them and a queued copy would arrive stripped. + Queueing them is tracked separately; this ships the loss-free behaviour. + - **A declaration that cannot be honoured fails the boot.** `queueDelivery: true` + from the constructor or `OS_EMAIL_QUEUE_ENABLED` with no durable queue + registered (or with `persist: false`) throws on `kernel:ready`, naming the + fix — the #5132 judgement, applied to durability. The **settings toggle** is + the opposite trade: it logs at `error` and keeps sending inline, because one + save must not stop the mail. + - The kernel's built-in in-memory `queue` fallback does **not** count as a + durable queue: it delivers synchronously with no retry or DLQ, so publishing + to it would report `queued` for a message nothing could ever recover. Mount + `@objectstack/service-queue` over an ObjectQL engine (the `queue` capability + does this on `os serve`) to get the `sys_job_queue`-backed adapter. + + Leaving `queueDelivery` unset keeps today's behaviour byte for byte. + +- d25f20b: fix(plugin-email): `sys_email` rows stranded at `queued` are swept at boot, and a failed drain says so at `error` (#5161) + + `status: 'queued'` had exactly one consumer: the `afterInsert` outbox drain that + fires during the insert itself (plus, since #5160, the `email.send.async` job + `send()` publishes). Nothing ever looked at such a row again. A process that + died between the insert and the delivery — or a drain whose delivery threw — + left the row at `queued` **forever**: a state named after a queue that had no + reader, while the caller had already been told the message was accepted. + + **A once-per-boot sweep is now that reader.** At `kernel:ready`, after the + registries are settled and the `email.send.async` subscriber is attached, + `sweepStrandedOutbox` picks up `sys_email` rows still at `queued` and advances + them: + + - **durable queue delivery on** → the row is published as an `{ rowId }` job to + `email.send.async` through the same producer, options and + `sys_email:` idempotency key `send()` uses, so a row that still has a + pending job collapses onto it instead of putting a second worker on it; + - **inline delivery** → the row is delivered and finalized in place (`sent` / + `failed`), which is what the drain hook would have done had the process lived. + + Only rows **older than five minutes** are eligible. A row inserted seconds ago + is not stranded, it is someone's in-flight work — this process's `send()`, its + deferred drain hook, or the same on another instance — and sweeping it would + send that message twice. (Age, not "created before this boot": one instance's + boot time says nothing about a sibling's row inserted a second ago.) Rows this + process is delivering right now, and rows that already carry a `message_id`, are + skipped. The batch is bounded at 500 rows per boot, oldest first, and says so + when it truncates. One `info` line reports the counts; boot does **not** wait on + the sweep, and a sweep that cannot run reports at `error` rather than relying on + `kernel:ready` error propagation. + + **Drain-hook failures are now `error`, not `warn`.** A drain that throws means + the mail was not sent while the insert reported success and the row still reads + `queued` — the durability class the degradation-log-level rule pins at `error`. + Both lines now name the consequence (this message was NOT sent, the row stays at + `queued`) and the fix (the boot sweep picks it up on the next restart; turn on + durable queue delivery to have failures retried and dead-lettered instead). + `deliverPersistedRow` joins `DURABILITY_CRITICAL_CALLEES`, so a future `catch` + that quietly downgrades it fails `pnpm check:durability-log-level`. + + New exports: `sweepStrandedOutbox`, `OUTBOX_OBJECT`, `OUTBOX_SWEEP_MIN_AGE_MS`, + `OUTBOX_SWEEP_LIMIT`, `EmailService.enqueuePersistedRow`, and + `EmailServicePlugin.outboxSweepSettled` (the sweep's promise, for callers that + need determinism). The normal `send()` → deliver path is byte-for-byte + unchanged. + +- 1b9a53b: plugin-email: large attachments (>256 KiB) now get durable queue delivery, with their content held out of the `sys_email` row + + A message whose attachments exceeded the in-row budget was pushed back onto inline delivery — whole, but with none of the durability queue delivery exists to provide, which meant the platform was weakest about exactly the mail that matters most (a signed contract, an exported report). Its content now goes to the `file-storage` capability, the row records a `storageKey` plus the audit metadata, and the queue worker fetches the content back to rebuild the message. + + - **Zero migration.** `attachments_json` declared `storageKey` from the start; this adds the producer and the reader. Attachments at or under `SYS_EMAIL_ATTACHMENT_LIMIT_BYTES` still go in the row exactly as before, and the boundary includes equality. + - **The row stays an audit log, not a blob store.** `filename` / `contentType` / `size` / `hash` stay on the row permanently; the content is a delivery artifact and is deleted a grace window (24h) after the row reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`. Reclamation is a delayed `email.attachment.reclaim` queue job that carries the storage keys, so a row deleted in the meantime reclaims its content instead of orphaning it. + - **Nothing degrades silently.** No `file-storage` capability, or an upload that fails, keeps today's behaviour — inline delivery of the whole message — and says which of the two it was and how to fix it. On the way back, content that cannot be fetched (outage, missing object, no capability on the worker, truncated or substituted bytes) fails the row loudly; a message is never delivered without an attachment it declares. + +- 8597a7d: fix(service-settings,plugin-email): the mail provider dropdown lists only providers that actually deliver (#5094) + + **Settings → Mail → Provider** offered `SMTP | SendGrid | Amazon SES | Postmark`. + `@objectstack/plugin-email` has never carried a SendGrid or an SES transport — + `makeTransport` knows `log` / `resend` / `postmark` / `smtp` and nothing else. So + selecting either of the two validated, saved, showed a success toast, and then + delivered no mail at all: the same declared-but-not-delivered gap #5087 closed + for SMTP, one field to the left. + + The same field broke the invariant in the other direction at the same time: + **`resend` has shipped a working transport all along and was not on the list**, + so nobody could pick the one HTTP provider that worked. + + **The dropdown is now `SMTP | Resend | Postmark | None (log only — no real +delivery)` — exactly the set `makeTransport` can build.** No email capability was + removed with SendGrid and SES. Both publish SMTP endpoints, and #5087 shipped a + real `SmtpTransport`, so both are configured today as `smtp`: + + | provider | host | port | credentials | + | :--------- | :---------------------------------- | :--- | :--------------------------------------------------------------------------------- | + | SendGrid | `smtp.sendgrid.net` | 587 | username `apikey`, password = your API key | + | Amazon SES | `email-smtp..amazonaws.com` | 587 | SES **SMTP credentials** (generated in the SES console — not your AWS access keys) | + + The provider field's own description says this, so the migration is in front of + whoever goes looking for the option that disappeared. + + `log` is listed rather than hidden. It is the one option that does not deliver — + but it does not pretend to: the label says so, `LogTransport` still records every + message to `sys_email`, and "Send test email" answers `ok: false` for it. That + gives an operator the deliberate, visible opt-out AGENTS.md asks a degradation to + be, instead of expressing "no outbound mail" as a half-filled SMTP form. It is + also what makes _offered_ and _deliverable_ the same set rather than merely + overlapping — which is the property a test can hold. + + **Already saved `sendgrid` or `ses`? Nothing breaks and nothing goes quiet.** The + stored value outlives the dropdown, so `applyMailSettings` now recognises it + explicitly: the previous transport is kept (a settings row written by an older + release must never fail a boot), and the server logs at `error` with both halves + AGENTS.md requires — the consequence (_no mail is delivered through it_) and the + fix (the SMTP settings above), not a bare "unknown provider". It is checked + _before_ the API-key check, because "set an API key" is the wrong instruction for + a provider that has nothing to hand a key to. "Send test email" refuses the same + way and sends nothing. Switching the provider to `smtp` and saving recovers the + transport without a restart. + + Two smaller corrections in the same field: + + - `api_key` is now shown and required for exactly `resend` and `postmark` + (`provider === 'resend' || provider === 'postmark'`). It was `provider !== +'smtp'`, which only worked because every non-SMTP option happened to be an + HTTP API; `required` is enforced server-side wherever the field is visible, so + that expression would have refused to save "None (log only)" until an API key + it never reads had been typed in. + - The built-in `mail/test` fallback (the one that runs when no email plugin is + mounted) rejects any `provider` outside the manifest's own option list instead + of answering "the form is well-formed". + + **Held by a test, in both directions.** `EMAIL_TRANSPORT_PROVIDERS` is now a + runtime array (the `EmailTransportProvider` union is derived from it), and + `plugin-email`'s `mail-manifest-providers.contract.test.ts` asserts set equality + between it and the manifest's option values, then builds a real transport for + each. Adding an option without a transport fails; adding a transport without an + option fails. `RETIRED_EMAIL_PROVIDERS` / `isEmailTransportProvider` / + `unsupportedProviderFix` are exported alongside it for hosts that surface the + same guidance. + +- 41c3b48: feat(plugin-email): real SMTP delivery — `SmtpTransport`, settings hot-swap, and a `mail/test` that actually sends (#5087) + + The **Mail Delivery** settings page has always defaulted to SMTP and offered a + full host / port / TLS / username / password form. Nothing behind it delivered: + `applyMailSettings` treated `provider: 'smtp'` as a no-op ("transport + unchanged"), `mail/test` answered `ok: true, "Configuration looks valid … Wire +@objectstack/plugin-mail for actual delivery"` — a success toast for a message + nobody sent, naming a package that has never existed — and the code pointed + operators at `@objectstack/plugin-mail-smtp`, which is not in this repo or on + npm. A workspace that selected SMTP got a green form, a green test button, and + mail that only ever reached the log and the `sys_email` table. For deployments + in China this left **no** working channel at all: Resend and Postmark are + overseas HTTPS SaaS with unreliable reach and deliverability to QQ / 163 / + enterprise mailboxes, where SMTP is the normal path (Aliyun DirectMail, Tencent + SES, corporate mail servers). + + **`SmtpTransport` now ships in `@objectstack/plugin-email`** (ADR-0012: SMTP in + core, implemented with `nodemailer`). `nodemailer` is a real dependency but is + imported **lazily on the first send**, so deployments that never select SMTP — + and non-Node runtimes — never load `node:net` / `node:tls`. + + Three doors reach it, all sharing one options reader so they cannot drift: + + - **Settings → Mail** (`smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` / + `smtp_password`) hot-swaps the live transport on save, no restart. + - **`os serve`** via `OS_EMAIL_PROVIDER=smtp` plus the new `OS_EMAIL_SMTP_HOST` / + `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` (or `config.email.options`). + - **Constructor**: `new EmailServicePlugin({ provider: 'smtp', providerOptions: +{ host, port, secure, user, password } })`. + + TLS is one toggle with the wire behaviour derived from the port, as providers + document it: on `465` implicit TLS (SMTPS); on any other port a **required** + STARTTLS upgrade, so a server that refuses to upgrade fails the send instead of + leaking credentials over a cleartext socket; `secure: false` connects in the + clear and upgrades only when STARTTLS is offered. + + **Failure is loud everywhere, because a silent fallback is the bug this fixes.** + On the construction path (CLI / plugin options) a `smtp` provider with no host + **throws** and the boot fails — it no longer degrades into a LogTransport that + reports every send as successful. On the settings hot-swap path a save can never + kill a running server, so the previous transport is kept — but the failure is + logged at `error` naming the consequence and the fix, and **`mail/test` now + performs a real delivery** through the settings on screen and reports the SMTP + server's own words (`535 … authentication failed`) instead of a green toast. The + built-in fallback `mail/test` handler (used only when no email plugin is + mounted) answers `ok: false` and says plainly that nothing was sent. + + Nothing to migrate: `log`, `resend` and `postmark` behave exactly as before, and + a deployment that never selects `smtp` is unaffected. + +- f104bab: feat(plugin-email,platform-objects): `sys_email` carries headers and small attachments, so those messages become durably deliverable (#5177) + + Durable email delivery works from the **row**, not from the in-memory message: + `send()` publishes an `{ rowId }` job (#5160), the boot sweep re-reads rows + (#5161), and both end at `rowToNormalized`. So anything a `sys_email` row could + not carry, a row-based delivery would have dropped — and custom headers and + attachments were exactly that. The honest workaround was to refuse: a message + with either was pushed back onto inline delivery so that it would at least go + out whole, which closed the durable path to precisely the mail most worth + making durable (a signed receipt, a `List-Unsubscribe` header, an invoice PDF). + + `sys_email` now has two columns, and those messages are queueable. + + **`headers_json`** — the custom headers, as a JSON object. Written in both + delivery modes (it is audit evidence as much as delivery input) and rebuilt on + read. Headers are no longer a reason to fall back to inline delivery. + + **`attachments_json`** — attachments as a JSON array of + `{ filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? }`, + content base64 in `inline`. Written when the **combined raw size of one + message's attachments is within `SYS_EMAIL_ATTACHMENT_LIMIT_BYTES` (256 KiB, + exported from `@objectstack/plugin-email`)** — worst case ~350 KB of base64, so + a row stays bounded. Both arms of the declared `content: string | Buffer` + contract round-trip as the arm they were sent as: restoring a text attachment + as a Buffer would silently drop `charset=utf-8` from its MIME part and let the + recipient's client mis-decode a UTF-8 file, so `contentForm` records which one + it was. `cid` travels too — an inline `` is unusable without + it. + + **Over the limit, nothing changes.** The message is delivered inline exactly as + before, whole, and the row stores no attachment content; the reason is stated + at `info` (a bound, not a degradation — the worst outcome is today's + behaviour). Out-of-row storage for large attachments is #5172; `storageKey` is + declared now so that lands as a new _producer_ rather than a data migration. + + Rows written before these columns exist read exactly as they did. A column that + is present but does not describe what it claims — malformed JSON, a size or + hash that disagrees with the content, a missing `contentForm` — is **rejected**, + and the row lands at `failed` carrying the reason, rather than being delivered + with a part quietly missing. + + The `sys_email` schema change is additive (two optional textarea columns); no + migration is required and default inline delivery is unchanged. + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 9de7155d16..f90da10213 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.2", + "version": "17.0.0-rc.3", "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 56225a761d..adb1c695b2 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,354 @@ # @objectstack/plugin-hono-server +## 17.0.0-rc.3 + +### Major Changes + +- 29c6c9d: feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) + + The declarative API-endpoint surface was **zero-execution end to end**, and said nothing + about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack` + accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact. + The execution side never fired once. On a real boot (showcase, 47 plugins) both declared + paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic + 404, because **no route was ever mounted** for a declared path, so the request died at + Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the + metadata service and called `matchEndpoint` on it — a method **no implementation in the + repo has ever provided**. The branch returned "not handled" on every request ever served. + + So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never + mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`, + `inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and + **`authRequired`**, a security semantic that parsed green and gated nothing at all. That + is false compliance, the failure ADR-0049 exists to stop, not debt. + + ## BREAKING — a non-empty `apis:` is now rejected + + Metadata that parsed cleanly before is now **refused at publish/validate**, with the + prescription in the rejection itself: + + ``` + apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, + so a non-empty array is rejected instead of silently accepted (#4936). … + ``` + + **FROM → TO.** `apis: [ …endpoints… ]` → `apis: []` (or delete the key; both are still + accepted, and an empty array is not a special case). To actually serve the route today, + mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server` + route. That is now the only honest path, and the one `examples/app-showcase` uses + (`src/system/server/recalc-endpoint.ts`). + + The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke + point every path runs through — `defineStack`, the metadata plugin's artifact ingestion, + `os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that + forgot to check. + + **The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and + rejected: endpoint shapes are an industry-stable form, so a retirement would only mean + re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript + and stay in the spec; only _authoring them into a stack_ is refused, and only until the + executor lands. Keep them commented next to your stack — that is what the showcase does. + The executor (route mounting + endpoint matching + per-key wiring for + `authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by + **#5040**, which replaces this rejection with real execution. + + ## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939) + + The repo carried a **second**, unrelated declaration shape for "an API endpoint": + `ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that + `createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every + assembly site lived in `packages/core/examples/`, with no registration in + `packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no + such service. The whole family was therefore inert, including + `ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense** + that "the gateway layer automatically validates these permissions" while no gateway read + it. Two declaration shapes, both dead; this retirement converges them on one. + + Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`, + `ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`, + `ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`, + `ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`, + `ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys). + Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`. + Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was + defaulted to `true` and read by nothing, configuring a service that was never composed. + + **FROM → TO.** There is no replacement shape to migrate to, because nothing executed the + old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`, + you were building a value only your own code read — keep it as your own type. Declarative + endpoints have one vocabulary now, `ApiEndpointSchema`. + + `ConflictResolutionStrategy` **survives** the removal and moved to + `@objectstack/spec/api`'s `router.zod` — same name, same four values + (`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two + independent ratchets and is not part of the retired surface. + + ## Also in this change + + - **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted, + along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves + `LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API + removal — but it returned `{ handled: false }` for every call it ever received, so no + caller can observe a behaviour change beyond the missing symbol. Delete the call. + Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing + at runtime with dead code behind it. + - `examples/app-showcase` no longer declares endpoints, and its coverage manifest no + longer claims the capability is `demonstrated` — that entry read "executed by the runtime + dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver + claim Prime Directive #10 forbids. + - The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**, + the live executor card, instead of #4936, which closes with this change. + +### Minor Changes + +- 2649ccb: feat(runtime,hono): 挂载 seam —— `setFallbackHandler` 实现 + 声明式端点派发步(#5040 E3, #5090) + + 给声明式 `apis:` 端点铺上**唯一一条**能进入处理器的通路,并且这条通路在构造上不可能遮蔽任何 + 已注册路由。执行器本身尚未落地,本次改动**零现网行为变更**:任何 stack 目前都无法发布非空 + `apis:`(publish 硬拒,直到 #5040 E7 翻转),所以这里新增的一切在真实组合里结构性不可达。 + + **`@objectstack/plugin-hono-server` —— `IHttpServer.setFallbackHandler` 的实现** + + 契约(#5080 落在 `@objectstack/spec/contracts`)的四条保证逐条兑现: + + - 映射到 Hono 的 `app.notFound` 钩子,**不是**通配路由。这是全部要点:通配路由要与之后注册 + 的每一条路由竞争,而 Hono 按先注册者赢裁决,归属就变成插件 `start()` 顺序的函数 —— + ADR-0076 D11 正是为此存在。兜底器只在全部显式路由未命中后运行,**零注册顺序依赖**。 + - handler 拿到的 `req.body` **可读**(与 `use()` 中间件 seam 相反,后者的契约明确不填充 + body),按 content-type 解析,与真实路由处理器走同一段代码。 + - 重复安装即**替换**,不成链。 + - handler 不写响应 → 适配器既有的未命中答案(404,或方法不匹配时 405 + `Allow`)原样保留。 + + 配套的一处属主收敛:404/405 应答此前由 `HonoServerPlugin.start()` 直接写在 + `getRawApp().notFound(...)` 上。`app.notFound` 是后调用者覆盖,兜底 seam 落在同一个钩子上, + 两个写入方意味着幸存者由插件启动顺序决定 —— 应答本体因此移入 `HonoHttpServer` + (`installNotFoundSeam()` / `setFallbackHandler()` 在其中组合),一个钩子一个属主。行为 + 逐字节不变(`notfound-405.test.ts` 原样通过)。 + + 顺带修好同一段代码上的两处不一致:适配器构造的 `IHttpRequest` 现在一律带 + `remoteAddress`(此前只有中间件 seam 有,同一个契约有两种形状);处理器**同步**抛出与 + 异步 reject 现在报同一种结果(此前同步抛出会逃到 Hono 自己的错误页)。 + + **`@objectstack/runtime` —— dispatcher 端点派发步** + + dispatcher-plugin 在 `start()` 中探测 `typeof server.setFallbackHandler === 'function'` + 并注册兜底器。对落在 ADR-0121 D1 保留段 `/apps/<命名空间>/<子路径>` 下的请求, + 探测 `metadata` 服务的 `matchEndpoint`(#5089 的实现在并行开发,探测缺席即穿透): + + - **命中** → `501 NOT_IMPLEMENTED`,包络说明执行器随 17.x 落地(#5040 E4–E5 接策略键与 + 执行目标); + - **未命中 / 无 matcher / 无 metadata 服务 / 路径不在挂载前缀下** → **不写任何响应**, + 传输层既有的 404/405 答案原样成立(有回归测试逐字节钉住); + - `matchEndpoint` 抛错按 5xx 出口应答,不降级为 404 —— 故障不得伪装成「没有这条路由」。 + + 派发步**不重入** `dispatch()`:那条管线会解析环境与 `executionContext`、跑匿名拒绝门、并以 + 语义 404 收尾,把全部未命中请求灌进去会改变今天未命中请求的答案。裸 404 与语义 404 的收口 + 是另一个决定,本次刻意不做。 + + `route-ledger.ts` 新增 `* /apps/**` 登记行与 `NON_DISPATCH_MOUNT_PREFIXES`(本包在 + `dispatch()` 之外挂载的前缀),注记如实描述已接线的部分与**尚未**接线的执行部分;新增 + 一致性测试钉住 ADR-0121 D1 赖以成立的事实 —— `/apps` 不属于任何内建域。 + +- 55dbbba: feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937) + + Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec` + declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any + of them, so an author wrote a budget, it parsed, and nothing happened (#4686). + `@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense, + that the dispatcher called it and short-circuited with 429 — it had **zero call sites** + outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to + tune did not exist (#4937). Neither half was broken; they were simply never connected, and + both were documented as if they were. + + They are connected now, along one narrow path. + + ## What you write + + ```ts + export default defineStack({ + manifest: { + /* … */ + }, + server: { + security: { + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 }, + }, + trustProxy: false, + }, + }); + ``` + + `server:` is a **new** top-level stack key. Nothing declared it before, so no existing + stack changes behaviour on upgrade — there is no configuration that was inert yesterday + and starts throttling today. + + It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and + nothing else, because those are the two keys with a consumer. It is NOT the nine-key + `HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and + mounting them here would have made seven dead keys writable in one move (their + enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled + budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0` + is refused at `defineStack` rather than at 3am. + + **No `server.port`.** The listening socket belongs to the deployment, not the artifact, and + `objectstack serve -p` already owns it. The precedence rule is recorded in the schema and + the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` > + built-in default.** + + ## What happens + + Every inbound request the server routes — REST, dispatcher, service routes, anything + mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`, + refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429** + with a `Retry-After` computed from the bucket itself and the standard error envelope + (`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered. + + The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for + anonymous traffic — so one abusive session cannot spend another user's budget, and + credential-stuffing traffic (which has no principal yet) is still metered per source. That + IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**; + otherwise it is the transport's own peer address. Undeclared, those headers are attacker + input: honouring them by default would hand anyone an unlimited supply of fresh buckets and + let them drain a chosen victim's. + + Counters live in the kernel `cache` service when one is registered, so a multi-node + deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at + consume time so a cache plugin that registers later is still picked up (#4772). With no + cache service at all it falls back to a per-process store and says so once, naming the + consequence: the effective limit becomes the declared budget multiplied by the number of + nodes, and nothing about the deployment looks wrong. + + ## Also in this change + + - **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation + passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered + middleware could not read the request, write a response, or decline to continue — a + declared seam with no execution behind it, unnoticed because nothing called it. It now + delivers method/path/query/headers plus the transport peer address + (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be + registered before the routes it guards; the kernel's two-phase boot makes that automatic + (`init()` before every `start()`). + - **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it + does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous + in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is + now labelled as the reference material it always was rather than as live defaults. + + ## Explicitly NOT wired + + `ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain + **known-unwired**. Declaring them still changes nothing. They are not retired here either: + the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one + key of a surface that may yet be implemented would only have to be undone. Tracked, not + silent. + +### Patch Changes + +- caf144a: ci(deps): OSV security batch 2026-08 — undici to 7.29.0, hono to 4.12.34, + fast-uri to 3.1.5, so `Validate Package Dependencies` stops failing on every PR (#5032) + + Eight advisories (2 high, 6 medium) matched packages resolved in `main`'s + `pnpm-lock.yaml`, and all eight name a fixed version: + + | advisory | CVSS | package | resolved | fixed | + | --------------------- | ---- | ---------- | ---------------- | ------- | + | `GHSA-7p8r-x3mc-p8w7` | 7.5 | `fast-uri` | 3.1.4 | 3.1.5 | + | `GHSA-8j4g-w8fx-2239` | 5.3 | `hono` | 4.12.32, 4.12.33 | 4.12.34 | + | `GHSA-4cwx-7wf7-3272` | 7.4 | `undici` | 7.28.0 | 7.29.0 | + | `GHSA-jr45-8vmc-qm54` | 5.9 | `undici` | 7.28.0 | 7.29.0 | + | `GHSA-8xcm-r25x-g524` | 4.8 | `undici` | 7.28.0 | 7.29.0 | + | `GHSA-v3r7-h72x-cjcm` | 4.8 | `undici` | 7.28.0 | 7.29.0 | + | `GHSA-m8rv-5g2x-5cg5` | 4.2 | `undici` | 7.28.0 | 7.29.0 | + + The OSV-Scanner step in `.github/workflows/validate-deps.yml` reads + `pnpm-lock.yaml` directly and exits non-zero on any match, so the job was red on + `main` itself and attached that red to every PR touching a manifest or the + lockfile, whatever the PR contained (observed on #5027, whose own lockfile delta + is three lines and resolves no new package). A permanently red gate is worse + than no gate: the next PR that really does introduce a vulnerable dependency + looks exactly like all the others. + + `undici` repeats the trap #4945 taught. The existing pin + (`undici@>=7.23.0 <7.28.0: ^7.28.0`, added for `GHSA-vmh5-mc38-953g`) had + settled on 7.28.0 — the version these five advisories affect — and its exclusive + upper bound no longer covered it, so the override sat there doing nothing. + Selector and target move together, to `<7.29.0` / `^7.29.0`. Transitive-only via + `@vscode/vsce` > `cheerio`; `@ai-sdk/provider-utils` already resolved 7.29.0, so + the two dedupe onto one copy. `jsdom`'s `undici` 8.9.0 is outside the selector + and untouched. + + `fast-uri` is transitive-only through `ajv@8.20.0` (declares `^3.0.1`), reaching + `@modelcontextprotocol/sdk`, `@objectstack/objectql`, `secretlint` and `table`; + a `fast-uri@<3.1.5: ^3.1.5` override covers all of them. + + `hono` is the one that is not transitive-only, which is why this changeset + releases something. Two versions were resolved: 4.12.32 from our own packages + and 4.12.33 pulled by `@modelcontextprotocol/sdk`. The override moves the + transitive copy and the declared ranges move with it — `@objectstack/plugin-hono-server` + `dependencies.hono` to `^4.12.34` (the published-manifest change this patch + covers), plus the `@objectstack/hono` and `@objectstack/plugin-auth` + devDependencies. Overrides do not ship with published packages, so a declared + range left behind would mean downstream resolves a version CI never ran — + exactly what `scripts/check-override-consistency.mjs` exists to catch. The + `@objectstack/hono` **peer** range stays the permissive `^4.12.8` on purpose: a + peer states which host `hono` the adapter works against, and a host that pins an + old one owns that copy. After the bump the workspace resolves a single + `hono@4.12.34`. + + Scope is the eight advisories #5032 lists and nothing else. #4965 (advisories + with no fix available, and the `osv-scanner.toml` exemption conventions that + answer them) is a separate question — every advisory here has a fix, so this is + an upgrade, not an exemption. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index bc09850430..2ed888a8d7 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.2", + "version": "17.0.0-rc.3", "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 1180609020..227c237b3a 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,32 @@ # @objectstack/plugin-pinyin-search +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [2b63a00] +- Updated dependencies [afa6aa5] +- Updated dependencies [08f93bc] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [5d3ced9] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [7c2f7dd] +- Updated dependencies [c5adfe1] +- Updated dependencies [1cae606] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [db2ea82] + - @objectstack/core@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index 373d0dfe96..0a637060bb 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.2", + "version": "17.0.0-rc.3", "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 f7ad1fdd19..957106c342 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,59 @@ # @objectstack/plugin-reports +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 22ddd3ee41..b4c658aef2 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.2", + "version": "17.0.0-rc.3", "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 7b64d7707c..5a6ec79138 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,185 @@ # @objectstack/plugin-security +## 17.0.0-rc.3 + +### Patch Changes + +- bf1edef: feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983) + + `isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4: + "exposed so an authoring-time gate (`objectstack compile`) can REJECT a + predicate the runtime would silently drop … A `false` here means 'this + predicate will never enforce'." It had **no non-test consumer anywhere** — the + function written to fix declared-but-never-read was itself declared and never + read. This lands the consumer, in two steps that had to happen in this order. + + **1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM + `@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula` + (`src/rls-predicate.ts`), and are exported from its root.** Executable code + unchanged — a change of address, not of behaviour; `plugin-security` now imports + them from `@objectstack/formula` and keeps no copy, so there is still exactly + one definition. No import path outside the two packages changes: neither symbol + was ever exported from `@objectstack/plugin-security`'s entry point. The move is + what makes step 2 possible at all — `@objectstack/lint` may depend on + `@objectstack/spec` and never on a runtime, so with the predicate living in a + runtime the gate's only other door was copying the SQL→CEL bridge, whose + boundary conditions (quoted literals are never rewritten; canonical CEL passes + through unchanged) _are_ the gate's red/green line. A fork drifting by one + character rejects policies the runtime executes correctly — the false-positive + direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical + shape gate; the bridge is part of that gate. + + **2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`, + `error`, on all three authoring commands**, over + `permissions[].rowLevelSecurity[].using` and `.check`: + + - **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown + subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a + cross-object path (`record.account.region`). + - **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy + SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery. + Its own id because the fix is different — write CEL (`&&`, `||`), not a + different shape. + + What the gate prevents, measured through `plugin-security` rather than inferred: + `RLSCompiler` drops the policy and logs one request-time WARN. On the read path, + when it is the only applicable policy, `compileFilter` returns the + `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so + every select / update / delete on the object matches **zero rows**. On the + ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which + no record satisfies, so every insert / update fails with `PermissionDeniedError`. + The runtime fails closed, which is why this was survivable: the result is not a + hole but a policy that reads as an authorization and behaves as a blanket + refusal, with nothing at authoring time pointing at the line that caused it. + + Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=` + `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and + `startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058 + D2), against a literal or a `current_user.*` value. Two specific migrations: + `has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object + _validation_ rule, which is interpreted, and wrong here, where the predicate is + compiled to a filter); and a related record's field → denormalise it onto this + object (formula/rollup) and test that column, since RLS cannot join (ADR-0055). + + Same construction as the sharing-rule gate (#4698): the rule does not model the + consumer or grep for it — it calls `isSupportedRlsExpression`, the exact + function `RLSCompiler.compileFilter` consults to decide whether a dropped policy + earns its warning, so the two verdicts are one boolean by construction, pinned + in both directions over a shared corpus. Measured before shipping: every RLS + predicate declared anywhere in this repo — the `plugin-security` platform seeds, + the examples, the dogfood fixtures, the authoring skill — is supported, so the + gate turns nothing red that works today. Unlike the sharing-rule gate, CEL + _syntax_ is reported here rather than deferred to `expression-invalid`: + `validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not + judge this field correctly if it did, because `owner_id = current_user.id` is a + CEL syntax error and a working RLS predicate at the same time. + +- 69a89ce: fix(plugin-security,plugin-sharing): the write path consults the View/Modify All Data bypass — one predicate for `security/explain` and `/data` (#4647) + + A **Modify All Data** holder, a `sharingModel: 'private'` object, and a record + whose `owner_id` is NULL got two opposite answers for one + (principal, record, operation) triple: + + ``` + POST /api/v1/security/explain { object, operation: 'update', recordId } + → allowed: true, layers[vama_bypass]: "View/Modify All Data bypass held + via [admin_full_access] — ownership and sharing checks are skipped" + PATCH /api/v1/data/crm_contract/ + → 403 FORBIDDEN + ``` + + Filling `owner_id` in made the same PATCH succeed, so the write path really was + running the record-level ownership check the bypass layer said had been skipped. + `sys_attachment`'s `canEdit(parent)` gate agreed with the 403, not with explain. + Ownerless rows are not exotic: a system-context seed writes them by design (the + seed loader disables `owner_id` injection). + + **The write path was the side that was wrong.** Modify All Data means an admin + edits any record regardless of ownership (the Salesforce reference frame this + platform's `modifyAllRecords` already follows, #1883), so: + + - `SharingService.canEdit` / `canDelete` now consult the super-user write bypass + **after** ownership and shares have failed, through the existing late-bound + `ISecurityService.hasWriteBypass` probe. The `sys_attachment` + `canEdit(parent)` gate and the sharing-rule management gate reach the same + answer because they call the same function. + - The bypass they consult and the one `security/explain` reports are now **one + predicate** — `PermissionEvaluator.superuserBypassSets` — rather than two + independent readings of the permission sets. A cross-path test pins the triple + through both `explain` and the real write middleware chain and asserts they + agree, for update, delete and the attachment gate. + + **The widening is exactly Modify-scoped.** `viewAllRecords` ("View All Data") is + a read power and never grants write: explain's `vama_bypass` layer is now + operation-aware, asking for the modify bit on a write and the view bit on a read, + and a view-only holder is refused on both paths. The probe still fails **closed** + — no `@objectstack/plugin-security`, a throwing probe, a principal-less or + on-behalf-of context all degrade to owner-only. + + **Explain payload self-consistency.** For a record-grained request the top-level + `allowed` and the `record` verdict no longer contradict each other on this + triple: the row is `visible: true` with `decidedBy: 'vama_bypass'`, the + `vama_bypass` layer carries its own per-record attribution, and the `sharing` + layer credits the bypass instead of reporting "no ownership and no edit/full + share grants write" next to `allowed: true`. Where the bypass is not what + admitted the row (owner, or an admitting share) the previous `decidedBy` is + unchanged. Note that for a principal with **no** bypass, an object-level + `allowed: true` beside `record.visible: false` remains correct and intended — + `allowed` answers the object question, `record` answers the row question, and it + is the `record` verdict that the write path mirrors. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 400c1317b1..d5c532c8b7 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.2", + "version": "17.0.0-rc.3", "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 5daab65ad7..10cc326c73 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,295 @@ # @objectstack/plugin-sharing +## 17.0.0-rc.3 + +### Patch Changes + +- 7f955e5: fix(sharing): deleting a record now revokes every `sys_record_share` row on it, whatever the source (#5103) + + A share row says "principal P has level L on (object O, record R)". Delete R and + the row describes nothing — yet until now it stayed in the table forever. + + #4779 (PR #5102) bound an `afterDelete` for this, but inside the sharing-RULE + package, where two conditions fenced it in: it revokes only `source: 'rule'` + rows, and it binds only on objects that appear in `sys_sharing_rule`. So an + object that uses nothing but MANUAL shares — a `sharingModel: 'private'` object + with no rule ever configured — had no delete hook at all, and **manual share + + record delete = a permanent orphan**. + + Today the harm is bounded, and only because record ids are never reused: the + `record_id IN (…)` predicate `buildReadFilter` emits matches nothing. Nothing + enforces that assumption. A custom primary key, an import that preserves ids, or + any future id recycling turns every one of those rows into a real privilege + escalation — a new record landing on a recycled id inherits the dead record's + recipients outright. Secondarily, `sys_record_share` grew without bound and + Setup's Record Shares list showed rows pointing at nothing. + + **What changed** + + - **A record-delete cascade on every sharing-capable object.** `plugin-sharing` + binds one `beforeDelete`/`afterDelete` pair with no object filter and judges + the object's sharing posture from its `sharingModel` metadata _per delete_. + Nothing is enumerated at boot, so nothing goes stale: an object that gains + `sharingModel` at runtime is covered on its very next delete, with no rebind. + Bounded deletes (a scalar id, an `$in` list, or a predicate matching at most + 1000 rows) are revoked synchronously and set-based; an unbounded one queues an + object-scoped orphan sweep instead. System-context deletes cascade too. + - **A boot-time orphan sweep keyed on record existence.** On + `kernel:bootstrapped`, share rows whose RECORD no longer exists are revoked — + historical orphans, rows a failed hook missed, and the one posture the cascade + deliberately skips (an unmarked system object). This is a different question + from the existing `sweepOrphanedRuleGrants`, which asks whether the RULE row + still exists and therefore can never see a manual share. Bounded per boot: + keyset pages, one batched existence probe per object per page, and a scan cap + that reports when it stopped early. An object whose existence probe FAILS has + its rows left in place — "could not ask" is never read as "the record is gone". + + **What did not change** + + Rule _recompute_ still never touches a manual share. That boundary (#5102) is + the point: while the record exists, a manual grant is a human decision no rule + evaluation may overrule. Only the record's DELETION revokes it, and only because + there is no longer anything to have access to. + + New exports for hosts that compose the plugin by hand: + `bindRecordShareCascade` / `unbindRecordShareCascade`, + `objectCanCarryRecordShares`, `SharingService.revokeSharesForDeletedRecords`, + `SharingService.sweepOrphanedRecordShares`, and `effectiveSharingModel`. Nothing + was removed or renamed; the standard `SharingServicePlugin` composition needs no + changes. + +- 6e66cbe: fix(plugin-sharing): a deleted record kills its share links — resolve fails closed, and the delete cascades (#5190) + + `ShareLinkService.resolveToken` checked the token, `revoked_at`, `expires_at`, + the audience and the password — **but never whether the record the link points + at still exists**. Nothing revoked links on delete either: #5103's cascade + covers `sys_record_share` only. So a share link outlived its record, kept + resolving, and kept stamping `use_count` / `last_used_at`. + + That is worse than the `sys_record_share` orphan #5103 fixed, and for a + structural reason: a share row names its beneficiaries, while a share link is an + identity-less **capability token** — holding the URL _is_ the authorisation. The + moment a record id is reused (custom primary keys, an import that preserves ids, + any future id recycling) a link that morally died with its record starts + authorising a brand-new record, for whoever kept it. + + Both halves of the fix ship together, and the first does not depend on the + second having run: + + - **`resolveToken` re-asks whether the record exists**, and returns `null` + through the _same_ branch as revoked / expired — no distinct code, no distinct + error, nothing an unauthorised holder can read "that record was deleted" out + of. The probe sits after the cheap in-memory gates (a revoked link still costs + no query) and _before_ the usage stamp, so a dead record no longer ticks + `use_count` / `last_used_at`. It fails **closed**: a probe that throws denies, + because "cannot ask" must not authorise. + - **Record deletes now cascade to `sys_share_link`**, on #5103's existing seam + rather than a parallel one — the same global `beforeDelete` row-set stash, the + same `afterDelete` set-based revoke, the same serialized sweep queue for + unbounded deletes, and the same `kernel:bootstrapped` orphan sweep (keyset + pages, a scan cap that reports itself, one batched existence probe per object + per page, and rows left strictly alone when that probe fails). The two halves + are isolated, so a driver error reclaiming grants cannot also skip the tokens. + + The link half judges posture from `publicSharing`, which is _independent_ of + `sharingModel`: the object most likely to hold links is a platform object that + opted into link sharing, and that is exactly the object the record-share + predicate skips. `publicSharing` declared counts even when it is currently + `enabled: false` — links minted while it was on outlive the flip. + + An orphaned link row is **deleted**, not stamped `revoked_at`: its subject is + gone, so there is no live link left to keep a revocation record of, and the + table would otherwise only grow (with Setup's link lists pointing at records + that no longer exist). Links an admin revokes keep their audit row exactly as + before. + + No metadata, spec or API shape changes. Deployments see fewer rows in + `sys_share_link` after the next boot, and links whose record was already deleted + stop resolving immediately — which is the point. + +- c272e48: fix(plugin-sharing): recompute sharing rules for predicate (`multi`) writes — stale `sys_record_share` grants no longer survive a bulk update (#4779) + + `bindRuleHooks` located the rows to recompute from a single record id: + + ```ts + const id = String(data?.id ?? ctx?.input?.id ?? ""); + if (!id) return; + ``` + + `ObjectQL.update()` only populates `input.id` when `where.id` is a scalar. A + predicate write (`multi: true`) routes to `updateMany`, leaves `input.id` + undefined, and carries no id in its payload — so **every bulk write skipped + sharing-rule recompute entirely**. + + The consequence is a fail-open on the authorization side. A criteria-based rule + materialises `sys_record_share` rows; an admin then bulk-updates those records + out of the criteria (`{ where: { region: 'east' }, multi: true, data: { region: +'west' } }`); nothing recomputes, the grant rows stay in the table, and the + recipients keep the read/edit access the rule no longer implies. Same family as + #4757 (`sys_attachment`) and #4778 (approval locks), but better hidden — a stale + grant is indistinguishable from a legitimate one. The reverse direction (bulk + update **into** a rule's criteria never granting) was broken too. + + **What changes** + + The hooks now key off the write's ROW SET instead of one id. `beforeUpdate` / + `beforeDelete` resolve the affected rows from the predicate and stash them on + the shared hook context (the `before` hook is where it must happen — the write + is what makes those rows unfindable); the `after` hook acts on them: + + - **Bounded set (≤ 1000 rows, `RULE_RECOMPUTE_ROW_CAP`)** — `evaluateAllForRecord` + per row, synchronously. Diff-based, so this covers both directions: rows moved + out of a rule's criteria are revoked, rows moved in are granted. + - **Unbounded set** (over the cap, `multi: true` with no `where` at all, or a + resolve that failed) — every `source: 'rule'` grant on the object is revoked + **synchronously** in one set-based statement, and the deserved grants are + restored **asynchronously** by reconciling the object's rules. + + **The write is never refused.** Refusing would turn an internal recompute bound + into a business-visible limit on how many rows an admin may update, reported by + a subsystem they never configured. The asymmetry it trades on instead: + over-granting is a security incident, under-granting is an availability wobble. + So the safety half is always synchronous and complete, and only the expensive + restoration half is deferred. + + **Operational note.** After a bulk write whose row set could not be bounded, + recipients may briefly lose access to records they still qualify for, until the + background re-grant finishes. It is logged with the object and the reason. The + re-grant is in-process; if it is lost to a crash, the plugin's existing + `kernel:bootstrapped` backfill re-runs the same idempotent reconcile on the next + start, and any subsequent `sys_sharing_rule` write reconciles too. + + **Also fixed:** the rule hooks now bind `afterDelete` and retire the deleted + records' rule grants. Nothing else could: `evaluateRule` iterates records that + still exist, so a grant whose record is gone was unreachable by every reconcile + path and outlived restarts. Harmless only while record ids are never reused — + an assumption nothing in the platform enforces. + + New on `SharingRuleService`: `revokeRuleGrantsForObject`, + `revokeRuleGrantsForRecords` and `evaluateAllRulesForObject`. Manual + (`source: 'manual'`) shares are never touched by any of them. + +- 69a89ce: fix(plugin-security,plugin-sharing): the write path consults the View/Modify All Data bypass — one predicate for `security/explain` and `/data` (#4647) + + A **Modify All Data** holder, a `sharingModel: 'private'` object, and a record + whose `owner_id` is NULL got two opposite answers for one + (principal, record, operation) triple: + + ``` + POST /api/v1/security/explain { object, operation: 'update', recordId } + → allowed: true, layers[vama_bypass]: "View/Modify All Data bypass held + via [admin_full_access] — ownership and sharing checks are skipped" + PATCH /api/v1/data/crm_contract/ + → 403 FORBIDDEN + ``` + + Filling `owner_id` in made the same PATCH succeed, so the write path really was + running the record-level ownership check the bypass layer said had been skipped. + `sys_attachment`'s `canEdit(parent)` gate agreed with the 403, not with explain. + Ownerless rows are not exotic: a system-context seed writes them by design (the + seed loader disables `owner_id` injection). + + **The write path was the side that was wrong.** Modify All Data means an admin + edits any record regardless of ownership (the Salesforce reference frame this + platform's `modifyAllRecords` already follows, #1883), so: + + - `SharingService.canEdit` / `canDelete` now consult the super-user write bypass + **after** ownership and shares have failed, through the existing late-bound + `ISecurityService.hasWriteBypass` probe. The `sys_attachment` + `canEdit(parent)` gate and the sharing-rule management gate reach the same + answer because they call the same function. + - The bypass they consult and the one `security/explain` reports are now **one + predicate** — `PermissionEvaluator.superuserBypassSets` — rather than two + independent readings of the permission sets. A cross-path test pins the triple + through both `explain` and the real write middleware chain and asserts they + agree, for update, delete and the attachment gate. + + **The widening is exactly Modify-scoped.** `viewAllRecords` ("View All Data") is + a read power and never grants write: explain's `vama_bypass` layer is now + operation-aware, asking for the modify bit on a write and the view bit on a read, + and a view-only holder is refused on both paths. The probe still fails **closed** + — no `@objectstack/plugin-security`, a throwing probe, a principal-less or + on-behalf-of context all degrade to owner-only. + + **Explain payload self-consistency.** For a record-grained request the top-level + `allowed` and the `record` verdict no longer contradict each other on this + triple: the row is `visible: true` with `decidedBy: 'vama_bypass'`, the + `vama_bypass` layer carries its own per-record attribution, and the `sharing` + layer credits the bypass instead of reporting "no ownership and no edit/full + share grants write" next to `allowed: true`. Where the bypass is not what + admitted the row (owner, or an admitting share) the previous `decidedBy` is + unchanged. Note that for a principal with **no** bypass, an object-level + `allowed: true` beside `record.visible: false` remains correct and intended — + `allowed` answers the object question, `record` answers the row question, and it + is the `record` verdict that the write path mirrors. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [2b63a00] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [5d3ced9] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [7c2f7dd] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [108ba8d] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [db2ea82] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 12d8d44adc..397038a449 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.2", + "version": "17.0.0-rc.3", "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 e3549fa02c..586c010f58 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/plugin-webhooks +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/service-messaging@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 3a8bfa3e34..621897c7f5 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.2", + "version": "17.0.0-rc.3", "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 008d689ee3..248d8435e9 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/dogfood +## 0.0.40-rc.3 + +### Patch Changes + +- Updated dependencies [f522e95] +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [c5e7bd9] +- Updated dependencies [0162c81] +- Updated dependencies [055f0c9] +- Updated dependencies [7cf1531] +- Updated dependencies [586d6f7] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [0161c7f] +- Updated dependencies [bcfebb0] +- Updated dependencies [533a0a4] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [3133cda] +- Updated dependencies [1f0e7cb] +- Updated dependencies [c142ced] +- Updated dependencies [c794f78] +- Updated dependencies [2b63a00] +- Updated dependencies [9c4f174] +- Updated dependencies [d25f20b] +- Updated dependencies [ecc61ab] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [729a43a] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [5d3ced9] +- Updated dependencies [9fa6bab] +- Updated dependencies [1b9a53b] +- Updated dependencies [7c2f7dd] +- Updated dependencies [95b4f0d] +- Updated dependencies [471839d] +- Updated dependencies [8597a7d] +- Updated dependencies [ff39e63] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [de113a4] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [7f955e5] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [b40f81c] +- Updated dependencies [6e66cbe] +- Updated dependencies [c272e48] +- Updated dependencies [9f41ee6] +- Updated dependencies [db2ea82] +- Updated dependencies [41c3b48] +- Updated dependencies [f61c8cf] +- Updated dependencies [718b229] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [dca25e1] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [e92e2c3] +- Updated dependencies [c183a12] +- Updated dependencies [69a89ce] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/service-analytics@17.0.0-rc.3 + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/metadata@17.0.0-rc.3 + - @objectstack/plugin-audit@17.0.0-rc.3 + - @objectstack/plugin-auth@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/example-showcase@0.3.14-rc.3 + - @objectstack/plugin-email@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/verify@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/plugin-sharing@17.0.0-rc.3 + - @objectstack/plugin-security@17.0.0-rc.3 + - @objectstack/service-storage@17.0.0-rc.3 + - @objectstack/example-crm@4.0.92-rc.3 + - @objectstack/connector-mcp@17.0.0-rc.3 + - @objectstack/connector-openapi@17.0.0-rc.3 + - @objectstack/connector-rest@17.0.0-rc.3 + - @objectstack/mcp@17.0.0-rc.3 + - @objectstack/plugin-webhooks@17.0.0-rc.3 + - @objectstack/service-messaging@17.0.0-rc.3 + ## 0.0.40-rc.2 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 8cc14fe640..d75475cd95 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.2", + "version": "0.0.40-rc.3", "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 9d4d890e23..a478a5190a 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/downstream-contract +## 0.0.38-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 0.0.38-rc.2 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 7cd94453c1..4f7b3b7450 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.2", + "version": "0.0.38-rc.3", "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 9c66a5c866..bc90359a2d 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,17 @@ # @objectstack/http-conformance +## 0.0.6-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [eb3e650] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [c5adfe1] + - @objectstack/core@17.0.0-rc.3 + ## 0.0.6-rc.2 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index b6dccb4f5a..a65cd2e2dd 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.2", + "version": "0.0.6-rc.3", "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 789319bca5..2297969e00 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,78 @@ # @objectstack/rest +## 17.0.0-rc.3 + +### Minor Changes + +- 1203bb2: **声明式端点进 OpenAPI 文档;`/openapi.json` 的影子属主摘除(#5040 E6,并入 #5078)** + + `GET {basePath}/openapi.json` 只有一个属主,而且实测坐实是 `packages/rest`(#5078:真实 boot 拿到 355KB 的 OpenAPI 3.1 文档,`servers[0]` 按 Host 注入、`{object}` 展开出 199 条 paths、两条 `x-template` —— 三个指纹全部是 rest-server 的行为)。因此 `apis:` 端点的文档面加入 **rest-server 既有的 enrichment 管线**(与 `{object}` 展开同根、同一次请求、同样 best-effort),而**不是**在某个 metadata service 上实现 `generateOpenApi` —— 那会造出 ADR-0076 第 1 条明令禁止的第二属主。E1 的契约成员因此已剔除。 + + 每条声明贡献一个 path 条目:`path` 原样、`method` 小写作为 Operation 键、`operationId` = `name`,以及词表**真正带有**的两个文档字段 `summary` / `description`(缺省即缺省,不生成替身)。除此之外只写「执行器会怎么对待这条声明」的事实,逐条注明出处:`object_operation` 的 `get`/`update`/`delete` 记录 id 取 `query.id`(词表无路径模板语法)、`create` 答 201 其余 200、`script` / `proxy` 与缺 `objectParams` 的 `object_operation` 答 **501**。不编造任何 request/response schema —— 出厂文档的 `components.schemas` 是空的,凭空写 `$ref` 只会得到悬空引用。 + + `authRequired` 由 schema parse 物化(缺省即 `true`),为 true 的条目引用**从文档自身读出**的 security 方案(不在 rest 里硬写方案名,否则就是第二处需要保持正确的地方),为 false 的条目写显式 `security: []` —— 这是 review 时一眼能看见的那个形状。不满足 `ApiEndpointSchema` 的存量条目**响亮跳过**并点名(与端点匹配器的装载门同一姿态);同 `method+path` 撞车时按「`name` 字典序在前者胜」裁决,与匹配器**同一条规则**,否则文档会指认一个运行时并不执行的端点;撞上内建路径时内建保留,声明被略过并报错。 + + 同时摘除 `http-dispatcher.ts` 里的 `generateOpenApi` 探测死分支:该方法在本仓与两个兄弟仓**零实现**,且 boot 实测**没有任何路由**把 `/openapi.json` 送进 `dispatch()` —— 双重死。`route-ledger.ts` 里对应的行与 `LEGACY_CHAIN_PREFIXES` 条目一并移除(原注记「falls through when metadata service lacks a generator」把「从来没有」写成了「有时没有」,正是 #5078 立单的失准点;把 prefix 留在一张自述为「if-chain 分支」的清单里,会在同一个 PR 里再造一次同样的谎)。该路由的唯一台账行在 `packages/rest/src/rest-route-ledger.ts`,一直是准的。 + + **现网行为零变更**:publish / validate 对非空 `apis:` 仍然硬拒(E7 前不撤),所以今天枚举出的是空集,enrichment 原样返回同一个文档对象 —— 服务出去的字节与本次改动前逐字节相同,并有测试钉住。 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + - @objectstack/service-package@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 5ee6493b58..cf4c60d6e1 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 140f9fd9d5..50bceb8f97 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,516 @@ # @objectstack/runtime +## 17.0.0-rc.3 + +### Major Changes + +- 29c6c9d: feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) + + The declarative API-endpoint surface was **zero-execution end to end**, and said nothing + about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack` + accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact. + The execution side never fired once. On a real boot (showcase, 47 plugins) both declared + paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic + 404, because **no route was ever mounted** for a declared path, so the request died at + Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the + metadata service and called `matchEndpoint` on it — a method **no implementation in the + repo has ever provided**. The branch returned "not handled" on every request ever served. + + So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never + mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`, + `inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and + **`authRequired`**, a security semantic that parsed green and gated nothing at all. That + is false compliance, the failure ADR-0049 exists to stop, not debt. + + ## BREAKING — a non-empty `apis:` is now rejected + + Metadata that parsed cleanly before is now **refused at publish/validate**, with the + prescription in the rejection itself: + + ``` + apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, + so a non-empty array is rejected instead of silently accepted (#4936). … + ``` + + **FROM → TO.** `apis: [ …endpoints… ]` → `apis: []` (or delete the key; both are still + accepted, and an empty array is not a special case). To actually serve the route today, + mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server` + route. That is now the only honest path, and the one `examples/app-showcase` uses + (`src/system/server/recalc-endpoint.ts`). + + The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke + point every path runs through — `defineStack`, the metadata plugin's artifact ingestion, + `os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that + forgot to check. + + **The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and + rejected: endpoint shapes are an industry-stable form, so a retirement would only mean + re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript + and stay in the spec; only _authoring them into a stack_ is refused, and only until the + executor lands. Keep them commented next to your stack — that is what the showcase does. + The executor (route mounting + endpoint matching + per-key wiring for + `authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by + **#5040**, which replaces this rejection with real execution. + + ## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939) + + The repo carried a **second**, unrelated declaration shape for "an API endpoint": + `ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that + `createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every + assembly site lived in `packages/core/examples/`, with no registration in + `packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no + such service. The whole family was therefore inert, including + `ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense** + that "the gateway layer automatically validates these permissions" while no gateway read + it. Two declaration shapes, both dead; this retirement converges them on one. + + Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`, + `ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`, + `ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`, + `ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`, + `ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys). + Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`. + Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was + defaulted to `true` and read by nothing, configuring a service that was never composed. + + **FROM → TO.** There is no replacement shape to migrate to, because nothing executed the + old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`, + you were building a value only your own code read — keep it as your own type. Declarative + endpoints have one vocabulary now, `ApiEndpointSchema`. + + `ConflictResolutionStrategy` **survives** the removal and moved to + `@objectstack/spec/api`'s `router.zod` — same name, same four values + (`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two + independent ratchets and is not part of the retired surface. + + ## Also in this change + + - **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted, + along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves + `LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API + removal — but it returned `{ handled: false }` for every call it ever received, so no + caller can observe a behaviour change beyond the missing symbol. Delete the call. + Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing + at runtime with dead code behind it. + - `examples/app-showcase` no longer declares endpoints, and its coverage manifest no + longer claims the capability is `demonstrated` — that entry read "executed by the runtime + dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver + claim Prime Directive #10 forbids. + - The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**, + the live executor card, instead of #4936, which closes with this change. + +### Minor Changes + +- 81e2744: 端点链接线:声明式 `apis:` 端点的派发步现在跑完整条链 —— 匹配 → 策略(`authRequired` / `rateLimit` / `cacheTtl`)→ 目标执行(`object_operation` 走 `/data` 同一个 `callData`,`flow` 走 automation 服务)。 + + 兜底器(`dispatcher-plugin`)补齐三根一直缺的线:把完整的 `EndpointPolicyContext`(请求头、`remoteAddress`、与 server 级限流器同一个会话查询、每端点限流注册表、`trustProxy`)喂给派发步;把 `answer.headers` **写到线上**(此前 429 的 `Retry-After` 会被丢掉,客户端拿到一个不知道何时重试的 429);并在匹配前解析本请求的环境 / 身份(与 `dispatch()` 同一个 `HttpDispatcher.resolveRequestScope`),使委派调用带着调用方的 `ExecutionContext` 运行,而不是以 system 身份绕过 RLS。 + + `cacheTtl` 的 `Cache-Control` 只挂在**成功**答复上,任何错误答复都不带它。多租户 host 若无法把请求解析到某个环境,该步**弃权**(不写任何东西,保留传输层原本的 404),而不是拿默认 kernel 的数据来回答。 + + 现网行为零变更:publish / validate 对非空 `apis:` 仍然硬拒(#5040 E7 前不撤),因此本链结构性不可达。 + +- 277eb36: **声明式端点的执行目标委派:`endpoint-executor` 纯模块(#5040 E5)** + + 命中的 `apis:` 端点按 `type` 委派到**既有**执行管线,零新执行语义 —— 这是 #5040 §4 的裁决:声明式端点是既有管线的「稳定 URL 别名 + 策略层」,不是第二套执行方言;同一操作经声明端点与经内建路由必须得到一致的答案。 + + - `type: object_operation` → `action-execution.callData`,参数形状逐条对齐 `/data`(`find`→`('query', {object, query})`、`get`→`('get', {object, id, select?, expand?})`、`create`→`('create', {object, data})`、`update`→`('update', {object, id, data})`、`delete`→`('delete', {object, id})`)。记录 id 取 `query.id`(词表未定义路径模板,执行器不发明一套);`object` **只**来自声明,请求改不动它。身份信封(`executionContext`)在五个操作上一律透传 —— #4936 摘除的死代码正是丢了这个参数,真跑起来会以 system 身份绕过 RLS。 + - `type: flow` → `IAutomationService.execute(target, buildAutomationContext(body, ctx))`,复用 `/automation` 触发路由**同一个**上下文构造函数(该函数因此从 `domains/automation.ts` 导出):`{recordId, objectName, params}` 翻译与完整身份信封转发一并继承,`runAs:'user'` 的流程不会 fail-closed 被拒(#3760)或以他人身份运行(#1888)。automation 槽为空或自称非 handler 时答 501,携带 discovery 同款处方句(ADR-0076 D12)。 + - `type: script` / `proxy`,以及缺 `objectParams` 的 `object_operation`:结构化 **501 NOT_IMPLEMENTED**(带处方),不猜语义。这个「不支持子集」在模块里只列一处,供 E7 的 publish 门直接照读。 + - 失败一律走既有错误包络:状态优先级与 `details` 组装照抄 `HttpDispatcher.errorFromThrown`(`.status` → `.statusCode` → 校验失败 400 → 兜底 500),5xx 消息过 `looksLikeInternalErrorLeak` 消毒(#3867/#3918)。新模块已加入 `error-envelope.conformance.test.ts` 的源码扫描名单。 + + **本次落地不接线**:调度步(`api-endpoint-step.ts`)命中后仍答 501,把它换成「策略 → 执行」链是随后的小型接线单(等 E4 #5091 一并落地);叠加 publish 对非空 `apis:` 的硬拒(E7 前不撤),该模块结构性不可达,现网行为零变更。 + +- 41e605e: **声明式端点的映射键:`inputMapping` / `outputMapping` 链内应用(#5040 E5c)** + + 两个键此前被 `ApiEndpointSchema` 声明、被 runtime 读取零次:作者写了、publish 放行、端点跑起来映射什么也不做 —— 正是 #5040 要消灭的「解析通过然后什么也不发生」中间态,也是 ADR-0049 `declared ≠ enforced` 的教科书形状(对 AI 写的元数据尤其糟:静默忽略的键不产生任何信号)。新纯模块 `api-mapping.ts` 是它们的唯一读者,语义**只**来自冻结词表的 describe 文本,取其最小忠实解读: + + - **`inputMapping`(_Map Request Body to Internal Params_)**:`source` 按点路径读**请求体**,投影出目标入参;在策略链通过之后、委派之前应用,因此映射永远买不通 `authRequired` / `rateLimit`,而 `endpoint-executor` 保持纯委派、对映射无感知。词表只说 body,**query 不并入**(合并会凭空发明一条谁覆盖谁的优先级规则),query 照旧原样抵达管线。 + - **`outputMapping`(_Map Internal Result to Response Body_)**:只作用于**成功**答案的载荷(`{success, data, meta}` 的 `data`),包络逐字保留 —— 声明改不动 `success`,也就无法把失败装扮成数据。401 / 429 / 400 / 501 一律不重映射。 + - **映射是投影,不是合并**:结果只由声明的 `target` 组成,未声明的字段不随行。出站方向因此天然是一份 allow-list —— `apis` 是平台的对外面(ADR-0121 D3),默认泄漏内部字段不是可接受的缺省。 + - **`source` 解析不到 ⇒ `target` 不写**(映射是投影不是校验器);**无声明 ⇒ 逐字节直通、按引用原样传递**,未声明映射的端点与 E5b 的行为完全一致。 + - **无法服务的声明响亮拒绝**,不静默跳过、不半应用:`transform`(全仓无「transformation function name」注册表,发明它是沙箱裁决而非映射细节)、不可用路径(空串、空段 `a..b`、`__proto__` / `prototype` / `constructor`)、互撞的 `target`(同路径或一个写进另一个内部)—— 均为结构化 **501 NOT_IMPLEMENTED**(带处方,点名具体条目如 `inputMapping[1].transform`),与 `endpoint-executor` 的 `unsupported` 分支同类同形。`outputMapping` 的这道判定在**委派之前**做:投影坏掉的 `create` 不该先插入记录再拒绝作答。 + - 新模块已加入 `error-envelope.conformance.test.ts` 的源码扫描名单。 + + **现网行为零变更**:非空 `apis:` 在 publish / validate 仍被硬拒(E7 #5111 前不撤),整条端点链结构性不可达。上述「不支持子集」应由 E7 的 publish 门在作者写应用时就拒掉,本模块是运行期兜底,不是主关口。 + +- 2649ccb: feat(runtime,hono): 挂载 seam —— `setFallbackHandler` 实现 + 声明式端点派发步(#5040 E3, #5090) + + 给声明式 `apis:` 端点铺上**唯一一条**能进入处理器的通路,并且这条通路在构造上不可能遮蔽任何 + 已注册路由。执行器本身尚未落地,本次改动**零现网行为变更**:任何 stack 目前都无法发布非空 + `apis:`(publish 硬拒,直到 #5040 E7 翻转),所以这里新增的一切在真实组合里结构性不可达。 + + **`@objectstack/plugin-hono-server` —— `IHttpServer.setFallbackHandler` 的实现** + + 契约(#5080 落在 `@objectstack/spec/contracts`)的四条保证逐条兑现: + + - 映射到 Hono 的 `app.notFound` 钩子,**不是**通配路由。这是全部要点:通配路由要与之后注册 + 的每一条路由竞争,而 Hono 按先注册者赢裁决,归属就变成插件 `start()` 顺序的函数 —— + ADR-0076 D11 正是为此存在。兜底器只在全部显式路由未命中后运行,**零注册顺序依赖**。 + - handler 拿到的 `req.body` **可读**(与 `use()` 中间件 seam 相反,后者的契约明确不填充 + body),按 content-type 解析,与真实路由处理器走同一段代码。 + - 重复安装即**替换**,不成链。 + - handler 不写响应 → 适配器既有的未命中答案(404,或方法不匹配时 405 + `Allow`)原样保留。 + + 配套的一处属主收敛:404/405 应答此前由 `HonoServerPlugin.start()` 直接写在 + `getRawApp().notFound(...)` 上。`app.notFound` 是后调用者覆盖,兜底 seam 落在同一个钩子上, + 两个写入方意味着幸存者由插件启动顺序决定 —— 应答本体因此移入 `HonoHttpServer` + (`installNotFoundSeam()` / `setFallbackHandler()` 在其中组合),一个钩子一个属主。行为 + 逐字节不变(`notfound-405.test.ts` 原样通过)。 + + 顺带修好同一段代码上的两处不一致:适配器构造的 `IHttpRequest` 现在一律带 + `remoteAddress`(此前只有中间件 seam 有,同一个契约有两种形状);处理器**同步**抛出与 + 异步 reject 现在报同一种结果(此前同步抛出会逃到 Hono 自己的错误页)。 + + **`@objectstack/runtime` —— dispatcher 端点派发步** + + dispatcher-plugin 在 `start()` 中探测 `typeof server.setFallbackHandler === 'function'` + 并注册兜底器。对落在 ADR-0121 D1 保留段 `/apps/<命名空间>/<子路径>` 下的请求, + 探测 `metadata` 服务的 `matchEndpoint`(#5089 的实现在并行开发,探测缺席即穿透): + + - **命中** → `501 NOT_IMPLEMENTED`,包络说明执行器随 17.x 落地(#5040 E4–E5 接策略键与 + 执行目标); + - **未命中 / 无 matcher / 无 metadata 服务 / 路径不在挂载前缀下** → **不写任何响应**, + 传输层既有的 404/405 答案原样成立(有回归测试逐字节钉住); + - `matchEndpoint` 抛错按 5xx 出口应答,不降级为 404 —— 故障不得伪装成「没有这条路由」。 + + 派发步**不重入** `dispatch()`:那条管线会解析环境与 `executionContext`、跑匿名拒绝门、并以 + 语义 404 收尾,把全部未命中请求灌进去会改变今天未命中请求的答案。裸 404 与语义 404 的收口 + 是另一个决定,本次刻意不做。 + + `route-ledger.ts` 新增 `* /apps/**` 登记行与 `NON_DISPATCH_MOUNT_PREFIXES`(本包在 + `dispatch()` 之外挂载的前缀),注记如实描述已接线的部分与**尚未**接线的执行部分;新增 + 一致性测试钉住 ADR-0121 D1 赖以成立的事实 —— `/apps` 不属于任何内建域。 + +- a70cd0a: 声明式端点的策略键接线:`authRequired` / `rateLimit` / `cacheTtl`(#5040 E4) + + 新增 `packages/runtime/src/endpoint-policy.ts` —— `ApiEndpointSchema` 三个策略键的唯一读取方,并接入端点派发步(匹配命中 → 策略链 → 答复)。三个键全部复用既有原语,零发明: + + - `authRequired`:复用 `shouldDenyAnonymous` 与 `ANONYMOUS_DENY_*` 常量,未认证得到与 `/meta`、`/ai`、`/security` 完全相同的 401 包络。默认值由 schema 物化(缺省即 `true`),执行器读不到「未声明」这个中间态;`authRequired: false` 是唯一的开门方式,且在 diff 中可见。 + - `rateLimit`:复用 #5006 的 `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter`,桶键为 `apiep:<端点名>:<主体或 IP>` —— 与 server 级预算各自独立计量,互不侵蚀。超限答与 server 级限流器逐字节一致的 429 + `Retry-After`。 + - `cacheTtl`:仅响应头语义(不实现服务端缓存,#5091 已裁)。正值 → `Cache-Control: private, max-age=`(`private` 是安全规则:任何响应都可能是按主体裁剪过的);`0`/负值 → `no-store`;缺省 → 不发头;非 GET → 不发头并 warn 点名。 + + 链序按 #5040 §3:**先限流、后鉴权**、再算缓存头 —— 凭据爆破本就是匿名流量,先答 401 会让扫号者完全绕开计量。 + + **结构性不可达、零现网行为变更**:非空 `apis:` 在 publish/validate 仍被硬拒(E7 翻转前),且派发步在未获得策略上下文时的答复与此前逐字节相同。 + +- 55dbbba: feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937) + + Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec` + declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any + of them, so an author wrote a budget, it parsed, and nothing happened (#4686). + `@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense, + that the dispatcher called it and short-circuited with 429 — it had **zero call sites** + outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to + tune did not exist (#4937). Neither half was broken; they were simply never connected, and + both were documented as if they were. + + They are connected now, along one narrow path. + + ## What you write + + ```ts + export default defineStack({ + manifest: { + /* … */ + }, + server: { + security: { + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 }, + }, + trustProxy: false, + }, + }); + ``` + + `server:` is a **new** top-level stack key. Nothing declared it before, so no existing + stack changes behaviour on upgrade — there is no configuration that was inert yesterday + and starts throttling today. + + It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and + nothing else, because those are the two keys with a consumer. It is NOT the nine-key + `HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and + mounting them here would have made seven dead keys writable in one move (their + enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled + budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0` + is refused at `defineStack` rather than at 3am. + + **No `server.port`.** The listening socket belongs to the deployment, not the artifact, and + `objectstack serve -p` already owns it. The precedence rule is recorded in the schema and + the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` > + built-in default.** + + ## What happens + + Every inbound request the server routes — REST, dispatcher, service routes, anything + mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`, + refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429** + with a `Retry-After` computed from the bucket itself and the standard error envelope + (`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered. + + The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for + anonymous traffic — so one abusive session cannot spend another user's budget, and + credential-stuffing traffic (which has no principal yet) is still metered per source. That + IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**; + otherwise it is the transport's own peer address. Undeclared, those headers are attacker + input: honouring them by default would hand anyone an unlimited supply of fresh buckets and + let them drain a chosen victim's. + + Counters live in the kernel `cache` service when one is registered, so a multi-node + deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at + consume time so a cache plugin that registers later is still picked up (#4772). With no + cache service at all it falls back to a per-process store and says so once, naming the + consequence: the effective limit becomes the declared budget multiplied by the number of + nodes, and nothing about the deployment looks wrong. + + ## Also in this change + + - **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation + passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered + middleware could not read the request, write a response, or decline to continue — a + declared seam with no execution behind it, unnoticed because nothing called it. It now + delivers method/path/query/headers plus the transport peer address + (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be + registered before the routes it guards; the kernel's two-phase boot makes that automatic + (`init()` before every `start()`). + - **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it + does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous + in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is + now labelled as the reference material it always was rather than as live defaults. + + ## Explicitly NOT wired + + `ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain + **known-unwired**. Declaring them still changes nothing. They are not retired here either: + the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one + key of a surface that may yet be implemented would only have to be undone. Tracked, not + silent. + +### Patch Changes + +- 18b8eaa: fix(runtime,tooling): `saveMetaItem` 进入持久性词表,包发布的可见性翻转不再静默丢写 (#4754) + + #4632 立的「Degradation log levels」规则由 `pnpm check:durability-log-level` 机械执行, + 但它只认 `DURABILITY_CRITICAL_CALLEES` 这张显式词表 —— 词表以外的持久性接缝它发现不了。 + #4669 的事故正是这一类:`protocol.saveMetaItem()` 失败被吞掉,整条投影路径停摆却一个红灯 + 都没有,跨了一个发布周期才被偶然看见。本次把 `saveMetaItem` 加进词表,并把它照出来的 + 每一处逐个判过。 + + **真丢失的那一处已修好。** `POST /packages/:id/publish-drafts` 的 ADR-0045 可见性翻转 + (`packages/runtime/src/domains/packages.ts`)是一次搭别人便车的元数据**写入**:草稿已经 + 发布,所以这个路由无论如何都答 200,而写失败只会在响应体里留下一个没人读的 `unhideError`。 + 症状因此是「我明明发布了,应用却没出现」,而且要很久以后才有人把它和这里联系起来 —— + 正是 #4669 的形状。现在它按范本在 `error` 级别报告:点名是哪个包、其 app 仍然以 + `hidden: true` 存着因而在启动器里不可见、发布却报告了成功,并给出修复动作(重跑 + publish-drafts,幂等;或直接 `PUT /meta/app/` 置 `hidden: false`),同时带上原始 + 错因。响应契约不变 —— 仍然是 200,仍然带 `unhideError`。 + + **闸门自身的两个精度缺陷一并修掉**(词表加一个条目就让它们暴露了,8 处命中里 4 处是误报): + + - **同文件 concise-arrow 报告器看不见。** 闸门文档明说 `catch` 一侧会追同文件的 helper, + 但遍历只访问子节点,而 `const logError = (...a) => console.error(...a)` 的函数体**就是** + 那个调用表达式本身,于是 `rest-server.ts` 里最响的两处 `/meta` PUT 反被判成「完全静默」。 + - **一处接缝被按嵌套层数重复指认。** 一个已被内层 `catch` 消化掉的调用,仍然算在每一层 + 外层 `catch` 头上 —— 而那些外层多半是正确的路由级错误处理器。`packages.ts` 里同一个 + `saveMetaItem` 因此被报了三次。现在只有当内层 `catch` 每条路径都向外传播时,外层才被 + 判定为真正的守卫。 + + 两个修复都在 `--self-test` 里双向钉住(改前必失败,改后才通过),自测用例由 CI 执行。 + + 判定为「故障已答给调用方」的三处(`meta.ts` 的 4xx/422、`protocol.ts` 两处结构化逐项 + 失败报告)不是降级,记入 shrink-only 基线并附理由与关闭条件(#5241)。 + +- 78adc2e: fix(runtime): disabled packages no longer come back enabled after an empty-env restart (#5047) + + An operator who disables a package has that decision persisted to + `/package-state/.json`, and boot replays it by seeding + the registry's initial-disabled set **before** any package is registered — so + every registration path (boot-artifact decomposition, `sys_packages` + rehydration, HTTP install) installs those packages disabled. + + That seed ran inside `AppPlugin.init` **after** the empty-env early return. An + empty environment is one whose artifact carries no app payload — which is + exactly the environment where every package arrives later, from + `PackageServicePlugin`'s Phase 2 replay of `sys_packages` or from an HTTP + install. So on precisely those DB-driven environments the initial-disabled set + stayed empty, and a package the administrator had disabled came back **enabled** + on every restart, with no error anywhere: the disable had persisted correctly, + it was simply never read. + + The seed now runs before that return, alongside the default hook/action body + runners and the authored-translation sync, which are before it for the same + reason. Non-empty environments are unaffected — the seed still lands before the + manifest is decomposed — and the seed remains best-effort, degrading silently on + kernels with no engine. + +- 1203bb2: **声明式端点进 OpenAPI 文档;`/openapi.json` 的影子属主摘除(#5040 E6,并入 #5078)** + + `GET {basePath}/openapi.json` 只有一个属主,而且实测坐实是 `packages/rest`(#5078:真实 boot 拿到 355KB 的 OpenAPI 3.1 文档,`servers[0]` 按 Host 注入、`{object}` 展开出 199 条 paths、两条 `x-template` —— 三个指纹全部是 rest-server 的行为)。因此 `apis:` 端点的文档面加入 **rest-server 既有的 enrichment 管线**(与 `{object}` 展开同根、同一次请求、同样 best-effort),而**不是**在某个 metadata service 上实现 `generateOpenApi` —— 那会造出 ADR-0076 第 1 条明令禁止的第二属主。E1 的契约成员因此已剔除。 + + 每条声明贡献一个 path 条目:`path` 原样、`method` 小写作为 Operation 键、`operationId` = `name`,以及词表**真正带有**的两个文档字段 `summary` / `description`(缺省即缺省,不生成替身)。除此之外只写「执行器会怎么对待这条声明」的事实,逐条注明出处:`object_operation` 的 `get`/`update`/`delete` 记录 id 取 `query.id`(词表无路径模板语法)、`create` 答 201 其余 200、`script` / `proxy` 与缺 `objectParams` 的 `object_operation` 答 **501**。不编造任何 request/response schema —— 出厂文档的 `components.schemas` 是空的,凭空写 `$ref` 只会得到悬空引用。 + + `authRequired` 由 schema parse 物化(缺省即 `true`),为 true 的条目引用**从文档自身读出**的 security 方案(不在 rest 里硬写方案名,否则就是第二处需要保持正确的地方),为 false 的条目写显式 `security: []` —— 这是 review 时一眼能看见的那个形状。不满足 `ApiEndpointSchema` 的存量条目**响亮跳过**并点名(与端点匹配器的装载门同一姿态);同 `method+path` 撞车时按「`name` 字典序在前者胜」裁决,与匹配器**同一条规则**,否则文档会指认一个运行时并不执行的端点;撞上内建路径时内建保留,声明被略过并报错。 + + 同时摘除 `http-dispatcher.ts` 里的 `generateOpenApi` 探测死分支:该方法在本仓与两个兄弟仓**零实现**,且 boot 实测**没有任何路由**把 `/openapi.json` 送进 `dispatch()` —— 双重死。`route-ledger.ts` 里对应的行与 `LEGACY_CHAIN_PREFIXES` 条目一并移除(原注记「falls through when metadata service lacks a generator」把「从来没有」写成了「有时没有」,正是 #5078 立单的失准点;把 prefix 留在一张自述为「if-chain 分支」的清单里,会在同一个 PR 里再造一次同样的谎)。该路由的唯一台账行在 `packages/rest/src/rest-route-ledger.ts`,一直是准的。 + + **现网行为零变更**:publish / validate 对非空 `apis:` 仍然硬拒(E7 前不撤),所以今天枚举出的是空集,enrichment 原样返回同一个文档对象 —— 服务出去的字节与本次改动前逐字节相同,并有测试钉住。 + +- 2ddba89: fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262) + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — therefore reads `false` from + `resolveMultiOrgEnabled()` while running a fully mounted organization wall. + #5233 corrected two sites in `plugin-auth`; a census found eight more, all + written before that function's doc comment was corrected. Third recurrence of + the shape (cloud#1020, #5233). + + Each site was judged separately for **which** posture answers its question — + what the operator REQUESTED, or what the `tenancy` service reports is actually + IN FORCE — rather than converted mechanically: + + - `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the + REQUESTED posture (it is constructed below the kernel, with no service + registry to ask). The `organization_id` column was always provisioned; what + diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's + hottest predicate unindexed while SecurityPlugin compiled that same wall. + - `plugin-dev` — whether to load the enterprise `@objectstack/organizations`. + REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall, + so asking whether the wall is up would be circular. A posture-only dev stack + previously never loaded the package at all and served traffic unwalled. Its + diagnostic now names the posture that was requested instead of asserting + `OS_MULTI_ORG_ENABLED=true` at an operator who never set it. + - `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture, + via the `tenancy` service. These ask "will the per-org replay run instead of + me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so + keying on the request would defer to a replay that can never happen. Walled + deployments previously inline-seeded exactly the NULL-organization rows the + code's own comment exists to avoid. + - `cloud-connection` marketplace local install (install-time seed + rehydrate + heal) — EFFECTIVE posture, same reasoning. The install path is a write path: + a walled deployment wrote every sample row with no `organization_id`, landing + the app's data outside the wall its own reads apply. + - `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no + kernel to ask, and a suppressed warning is the costlier error for a + diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a + process-level fact into a per-instance verdict on whichever write landed + first. The gate now resolves live, which is affordable because + `auditMissingTenant` consults it only after the `tenantId` early-out. + - `cli` `os verify` — REQUESTED posture. This one produced a green verification + run over an unverified property: a posture-only deployment silently skipped + every multi-tenant proof and exited 0. + + **No configuration change is needed anywhere.** Deployments setting only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + belt-and-braces configuration stays valid. Deployments that set only + `OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is + unchanged at every site; only the knob each one reads is corrected. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [7cf1531] +- Updated dependencies [586d6f7] +- Updated dependencies [e96ad55] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [75bb3af] +- Updated dependencies [0161c7f] +- Updated dependencies [533a0a4] +- Updated dependencies [1f82d1e] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [3133cda] +- Updated dependencies [c142ced] +- Updated dependencies [c794f78] +- Updated dependencies [2b63a00] +- Updated dependencies [ecc61ab] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [729a43a] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [5d3ced9] +- Updated dependencies [9fa6bab] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [7c2f7dd] +- Updated dependencies [95b4f0d] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [de113a4] +- Updated dependencies [5b8f95b] +- Updated dependencies [da538b1] +- Updated dependencies [2ddba89] +- Updated dependencies [79822b5] +- Updated dependencies [15e61fb] +- Updated dependencies [37a8f2b] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [f98fa65] +- Updated dependencies [1d29e6d] +- Updated dependencies [c5a5996] +- Updated dependencies [5ea8e1e] +- Updated dependencies [b40f81c] +- Updated dependencies [db2ea82] +- Updated dependencies [f61c8cf] +- Updated dependencies [193cd5c] +- Updated dependencies [5aae790] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [dca25e1] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [e92e2c3] +- Updated dependencies [38f53a0] +- Updated dependencies [c183a12] +- Updated dependencies [69a89ce] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/metadata-protocol@17.0.0-rc.3 + - @objectstack/metadata@17.0.0-rc.3 + - @objectstack/plugin-auth@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/driver-sql@17.0.0-rc.3 + - @objectstack/rest@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + - @objectstack/plugin-security@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + - @objectstack/driver-memory@17.0.0-rc.3 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.3 + - @objectstack/service-cluster@17.0.0-rc.3 + - @objectstack/service-datasource@17.0.0-rc.3 + - @objectstack/service-i18n@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 38693fffff..c5e6ecddd8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 6426e80c00..4b2236b706 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.0.0-rc.3 + ## 17.0.0-rc.2 ## 17.0.0-rc.1 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 1214cd1e85..e034b7d145 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.2", + "version": "17.0.0-rc.3", "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 06cddea1f3..7649b8db0b 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,256 @@ # Changelog — @objectstack/service-analytics +## 17.0.0-rc.3 + +### Major Changes + +- d17df80: **BREAKING — `dashboard.widgets[].compareTo` converges on the analytics executor's contract (#5011).** + + The widget declared three period-over-period arms with confident TSDoc. The analytics + executor implements one shape, and it was never the same one — so on the ADR-0021 dataset + path (the spec's own "single author-facing analytics shape") **all three arms were + broken**, in two different ways: + + - `compareTo: 'previousPeriod'` / `'previousYear'` were **silently DROPPED** by the dataset + renderer. The widget rendered its base numbers and the comparison the author asked for + simply was not there. + - `compareTo: { offset: '7d' }` was forwarded into `DatasetSelection.compareTo`, whose + contract is `{ kind, dimension }` and has no `offset` in it — so the executor threw + `compareTo requires a timeDimension "undefined"` and the whole widget errored out. + + All three worked on the legacy inline chart path. Same key, two fates, and the failing one + was the path the spec calls canonical. + + `compareTo` is now a thin projection of the contract that is actually implemented: + + ```ts + compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension?: string } + ``` + + There is no widget-side vocabulary left to drift from the executor's, so `declared = +enforced` holds by construction rather than by review. + + ## FROM → TO + + | v16 | v17 | Fix | + | :----------------------------------------- | :-------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------ | + | `compareTo: 'previousPeriod'` | `compareTo: { kind: 'previousPeriod' }` | `os migrate meta --from 16` rewrites it | + | `compareTo: 'previousYear'` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it | + | `compareTo: { offset: '1y' }` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it — `1y` **is** `previousYear` | + | `compareTo: { offset: '7d' \| '1M' \| … }` | **no faithful target** | State the window on the widget's own `filter` and compare with `{ kind: 'previousPeriod' }`, which shifts by that window's own length | + + The last row is deliberately _not_ rewritten. `previousPeriod` shifts by the length of + whatever window the filter resolves to, which equals `7d` only when that window happens to + be seven days — a mechanical rewrite would silently change which rows the comparison + column counts, turning a loud failure into a wrong number. It is registered as the + `dashboard-widget-compareto-offset` semantic migration; the schema rejects the key with the + prescription in hand. + + Retired at the schema, so every old spelling is a parse error carrying its own upgrade — + including the bare strings, which are dispatched by value so a _typo_ is still told it is a + typo rather than told it "was removed". + + ## `dimension` is optional — resolved by the executor, not by a renderer + + Omit it and `dataset-executor.ts` resolves it, by its own long-standing criterion (a + `timeDimensions` entry carrying a `dateRange`): + + - exactly one candidate → that one is shifted; + - **zero** → a loud error: a comparison is only defined against a bounded window; + - **two or more** → a loud error **listing the candidates by name**, never a silent + first-wins. Picking `created_at` when the author meant `close_date` produces a comparison + that is _wrong_ rather than _missing_, which is the failure nobody audits. + + This is a producer-side resolution rule, not consumer-side tolerance (Prime Directive + #12): every caller — dashboard widget, report, raw `queryDataset` — gets the same dimension + or the same error, and no renderer is ever in a position to guess one. + + ## Notes + + - `DatasetCompareTo.dimension` is now optional. Callers that always passed it are + unaffected; callers that relied on the old "must be present" typing get a wider type. + - The converged slot is **union-free**. That is not cosmetic: zod collapses a failed union + into one bare `Invalid input`, so curated guidance written inside a union arm never + reaches the author (#5014). This slot's prescriptions are top-level and do. + - objectui's legacy inline chart path adapts separately (objectui#3337), which also deletes + the `DatasetWidget` string-drop workaround this change makes unnecessary. + +### Minor Changes + +- 1f0e7cb: fix(service-analytics): reject a dataset's cross-datasource JOIN when it is compiled, not when it is queried (#5115) + + #5033 routed a dataset's raw SQL to its base object's own datasource, which + turned a JOIN whose target lives in another database into a **loud query-time + failure** — correct, but late: the dataset can still be saved, published and + put on a dashboard, and the failure lands in front of whoever opens that + dashboard, usually in another environment on another day. It is a pure metadata + error, decidable the moment the dataset is compiled: the whole dataset is + lowered into ONE statement on the base object's datasource, so a join target + bound elsewhere is simply not there. + + `compileDataset` now decides it. `AnalyticsService.registerDataset` — the single + door every dataset passes through, whether pre-registered at boot, saved, or + previewed as a Studio draft — hands the compiler the datasource and federation + probes that already existed on `AnalyticsServiceConfig`, and a proven conflict + is rejected before any SQL is built. The message names both objects, both + datasources, the offending `include` path, and the two ways out (bind both + objects to the same datasource, or drop the relationship), in the same wording + family as the #5033 query-time diagnostic so the two never read as two bugs. + + **Who is affected.** This is a tightening: a dataset that used to compile and + then fail (or, before #5033, silently read the wrong database) now fails at + registration. It fires only where the metadata _proves_ the conflict — the base + object and a join target each declare an explicit `object.datasource` and the + two names differ. A dataset registered at boot is skipped with a WARN naming the + conflict, as before; the rest of the host's datasets still register. + + **What is deliberately not rejected** ("cannot answer, do not block", the same + tiering as `isRegisteredObject` / `getObjectFieldNames`): + + - a host that wires no datasource probe at all (no data engine) — compiles + exactly as it did before; + - either side leaving `datasource` at its default. `'default'` is the schema's + default _value_, not a routing decision: `ObjectQL.getDriver` short-circuits + only on an explicit non-`'default'` name, then falls through to + `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split + (audit/telemetry/event) and the owning package's `defaultDatasource` — none of + which are visible to the compiler. Treating `'default'` as "the primary DB" + would reject datasets whose objects a mapping rule in fact lands on the _same_ + database; + - a federated (external) participant on either side. `NativeSQLStrategy` already + declines such a cube (ADR-0062 D6), so the query is served by the ObjectQL + FK-expand path, which crosses datasources by construction. + + Everything not proven here keeps failing loudly at query time via #5033. + Making cross-datasource dashboards actually _work_ (declining in + `NativeSQLStrategy` and serving the join with two reads) is separate and not + part of this change. + +### Patch Changes + +- f522e95: fix(service-analytics): the dataset raw-SQL bridge routes by object, so datasets over non-default datasources stop reading `0` (#5033) + + `AnalyticsServicePlugin`'s `executeRawSql` auto-bridge received the object name + and threw it away: `engine.execute(knexSql, { args: params })`. `ObjectQL.execute()` + picks its driver in the order `options.object` → `getDriver(object)`, then + `options.datasource`, then the default driver — so rule 1 could never fire and + **every dataset raw-SQL read landed on the default datasource**. Any object routed + elsewhere (the ADR-0057 §3.6 telemetry split for `lifecycle.class ∈ {audit, +telemetry, event}`, an explicit `object.datasource`, a `datasourceMapping` rule) + raised `no such table`, which the widget-level graceful degradation then turned + into an empty result — a confident `0` over live rows, on a green dashboard. + Measured: `sys_audit_log` returned 49 records through the object-routed read and + `{"rows":[]}` through the dataset raw-SQL read, on the same running kernel. + + The bridge now passes `{ args: params, object: objectName }`, matching the + `executeAggregate` bridge beside it (`engine.aggregate(objectName, …)`), so both + dataset execution paths give **one** answer to "which datasource is this object in". + No configuration change is needed; misrouted dashboards start reading real data. + + **Behaviour change worth knowing about.** A dataset whose SQL `LEFT JOIN`s (what + `NativeSQLStrategy` emits for a dotted dimension such as `account.industry`) across + two datasources previously ran against the default datasource and silently read the + wrong database. It now runs on the base object's own datasource, where the joined + table genuinely is not — and **fails loudly** instead of degrading, because the base + table resolved fine and reporting it as "unavailable" would keep the confident `0` + alive under a new cause. The error names the actual cause and the remedy: + + ``` + [Analytics] dataset "audit_by_actor" cannot be executed as one statement: + table "account" is not on datasource "telemetry", which is where its base object + "sys_audit_log" lives — "account" is registered on the default datasource. + A dataset JOIN cannot cross datasources. Fix it by binding both objects to the + same datasource, or by dropping the cross-datasource relationship from the + dataset's `include`/dimensions. + ``` + + Graceful degradation is unchanged for genuine absence: a dataset whose own backing + object (or a joined object that this kernel never registered) has no table still + renders as "no data" with the existing server-side `warn`, rather than failing the + widget. `AnalyticsServiceConfig` gains one optional, diagnostics-only hook — + `getObjectDatasource(objectName)` — used solely to name the datasources in that + message; it never selects a driver. + +- ff39e63: fix(service-analytics): 维度合并键不再把「未分配」并进「空白」,并改为长度前缀消歧 (#4821) + + `mergeByDimensions` 是每一份多查询 dataset 结果的装配缝:主查询与每个带 `filter` + 的 measure 的补充子查询在这里对齐,`compareTo` 窗口自 #4870 起也按 measure 扇出后 + 经由同一个缝合并回来。这里一次键碰撞不会报错 —— 一个分组静默吸走另一个分组的数字, + 网格仍然保持看起来合理的行数和列数。 + + **#4821 报告的机制与实际的缺陷不完全一致,先把这一点说清楚。** 原键是 + `String(row[d] ?? '')` 以一个**直接写进源码的裸 U+0001 字节**相连。裸控制字符渲染 + 为空,所以 issue 正文读到的是 `join('')`,其头号复现(`['ab','c']` 与 `['a','bc']` + 同键为 `"abc"`)其实并不成立 —— 分隔符一直在,只是看不见。真正咬人的是另外两条: + + - `?? ''` 让**真正为 null** 的维度与**空字符串**维度键成同一个值。于是「未分配」被 + 并进「空白」:一行吞掉另一行的 measure,另一行的列则整个缺失 —— 而 #4708 的空组 + 填充随后会给它填上一个理直气壮的 `0`。一个真实计数为 3 的分组因此显示为 0。 + - 单字符分隔符只在「没有任何维度**值**包含该字符」时才无歧义。维度值是用户数据 + (文本字段、导入记录),所以那是一个假设而非保证,且一旦不成立同样静默。 + + **改法:长度前缀 + 显式空值哨兵。** 每段编码为 `<长度>:<值>`,`2:ab1:c` 与 + `1:a2:bc` 对任意输入都不同,不再保留任何字符、也不再有看不见的字节留给下一个读者 + 误读(本 issue 正是这样被误读出来的)。null/undefined 单独走一个哨兵段,与消歧这件 + 事解耦。 + + **逐段的 `String()` 强制被刻意保留**,这与一文件之隔的 `cross-object-rebucket.ts` + 的 JSON 键不是同一笔交易:后者重新分桶的是**同一个查询**的行,一列只有一种类型, + JSON 在那里免费且能换来真实的区分(空桶 `null` vs 字面量字符串 `"null"`)。本函数 + 做的是相反的事 —— 跨**不同查询**对齐行,而驱动确实会对同一个分组返回不同的 JS 类型 + (本文件 `compareValues` 的注释即记着 "numeric strings, which is how some drivers + return SUM results")。改用 `JSON.stringify` 会把 `1` 与 `"1"` 渲染成两个键,让今天 + 能正确合并的行不再合并 —— 用一个新的静默缺陷换掉旧的,不算修好。该行为已有回归钉 + 测试锁住。 + + 仅影响内部合并键,响应中的任何值都不改变。 + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index b32be0f797..282c2ad765 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.2", + "version": "17.0.0-rc.3", "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 c0a995a2b8..03b0da03e9 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,175 @@ # @objectstack/service-automation +## 17.0.0-rc.3 + +### Major Changes + +- 4845f85: **The retry policy's last two dialects converge** (#4964 `flow.errorHandling`, #4962 + `ETLPipeline.retry`). + + #4661 converged the retry policy onto one declaration. It converged the two shapes that + published the **same exported name** (`RetryPolicy` from `./automation` and `./system` — + the #4411 trap), because that is the question the dual-source instrument asks. Two more + encodings of the identical concept were outside its vision _by construction_: both are + anonymous inline `z.object`s nested in a bigger schema, with no exported name to collide. + + The cost of the gap fell on the author who did the right thing. `shared/retry-policy.zod.ts` + tombstoned `retryDelayMs` and told them to write `backoffMs` — and `flow.errorHandling` + then **rejected** `backoffMs` and demanded `retryDelayMs`. Reading the newer file was + punished, and which file an AI author reads first is arbitrary. + + All four surfaces — `job.retryPolicy`, a `try_catch` node's `retry`, `flow.errorHandling` + and an ETL pipeline's `retry` — now build from one shared shape. + + ## FROM → TO + + ### `flow.errorHandling` (#4964) + + | | FROM | TO | + | ----------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------ | + | base delay | `retryDelayMs`, min 0, default 1000 | **`backoffMs`**, min 0, default 1000 | + | `maxRetries` / `backoffMultiplier` / `maxRetryDelayMs` / `jitter` | _(already identical)_ | unchanged | + | `strategy` | `'fail' \| 'retry' \| 'continue'` | unchanged — it selects _whether_ the policy runs, so it stays outside it | + + One key, one word, no default changes. Every other key, bound and default already + matched the converged policy, which is exactly why the divergence survived a release: + it looked reviewed. + + ### `ETLPipeline.retry` (#4962) + + | | FROM | TO | + | ------------------- | ---------------------------------------------- | ----------------------------------------- | + | count | `maxAttempts`, min 0, **default 3**, unbounded | **`maxRetries`**, 0–**10**, **default 0** | + | base delay | `backoffMs`, default **60000** | `backoffMs`, default **1000** | + | `backoffMultiplier` | _(absent)_ | ≥1, default 1 | + | `maxRetryDelayMs` | _(absent)_ | default 30000 | + | `jitter` | _(absent)_ | default false | + + ## What you must change + + **1. Rename `retryDelayMs` → `backoffMs`** in any `flow.errorHandling` block. The value + (milliseconds before the first retry) is unchanged. The old spelling is **tombstoned**, + not deleted, so it rejects with the rename rather than being silently stripped, and + `os migrate meta --from 16` (the `retry-policy-converged` conversion, now with a + flow-level branch) rewrites it for you. + + **2. Rename `maxAttempts` → `maxRetries`** in any `ETLPipeline.retry` block. **The number + does not change** — both counted the retries _after_ the initial attempt. Do **not** + subtract one: that adjustment belongs to `integration/connector.zod.ts`'s + identically-spelled `RetryConfig.maxAttempts`, which _includes_ the first attempt and is + deliberately **not** part of this convergence. + + **3. If an ETL pipeline relied on the implicit retry count, write it out.** `retry: {}` + used to mean three re-runs 60s apart; it now means **none**. State `maxRetries: 3` (and + `backoffMs: 60000` for the old delay) to keep the old behaviour. + + ## Why the ETL default flips to 0 + + Not merely to follow #4661. An ETL destination is a foreign system _by definition_ — a + warehouse, an API, someone else's database. A silent retry against a non-idempotent + destination is a **duplicate write**: a second invoice, a second export, a second + webhook. Default 0 makes retrying something an author states, and thereby claims + idempotency for. An unstated key is precisely where LLM-authored metadata hides this. + + ## Migration surface + + **`flow.errorHandling`** is live: `service-automation`'s `retryExecution` reads the key + (it now destructures `backoffMs`), and the D2 conversion covers stored and authored + flows, so no deployed stack changes behaviour. + + **`ETLPipeline.retry` has an empty migration surface today, and that is why now was the + moment.** `etl.zod.ts` has no parse site in objectstack / objectui / cloud (批 12's + measurement) and an ETL pipeline is not a `defineStack` collection, so there is no stored + document a conversion could walk — it deliberately gets a tombstone and **no** D2 step, + rather than a walker advertising coverage that does not exist. Once an ETL engine lands, + flipping this default stops being a schema edit and becomes a behaviour change to every + deployed pipeline. + + ## Also + + The two automation retry surfaces now carry the **same** curated unknown-key table, so an + author learns one lesson instead of two, and `retry-policy.test.ts` gains a + concept-level guard: all four surfaces are asserted to expose the same key set and the + same defaults, by parse rather than by inspecting how each obtains them. Adding a fifth + retry surface without wiring it to the shared shape now fails a test — which is the check + that would have caught both of these issues, and the one the name-based scan could never be. + +### Patch Changes + +- c5adfe1: fix: 节点执行与热重载 shutdown 的超时守卫在 race 落定时被清除,不再留下孤儿定时器 (#4952) + + #4813(PR #4874,内核 init/start)与 #4875(PR #4950,周期性健康检查)修掉的是同一种漏法: + 守卫 armed 之后就被扔掉 —— 被守护的一方赢下 race 之后,那根 `setTimeout` 既没 `clearTimeout` + 也没 `unref()`,带着 ref 一直把事件循环钉满整个超时预算。本次清仓剩下的两处生产实例: + + - **`AutomationEngine.executeWithTimeout()`**(`service-automation`)—— 三处里量级最大的一处: + **每个声明了 `timeoutMs` 的流程节点各一根**,孤儿数随流程节点数 × 触发频率线性增长;一次性进程 + (`os` CLI 跑到 flow 的路径)干完活之后还会被最长的那根守卫按住到超时才退出。 + - **`HotReloadManager.reloadPlugin()`**(`core`)—— 插件 `destroy()` 的 shutdown 守卫,与 #4813 + 修掉的两处一字不差:一次毫秒级完成的热重载,照样把循环钉满 `shutdownTimeout`。 + + 两处修法与 #4874 / #4950 同形,不新造变体:私有 helper + + `try { return await Promise.race([...]) } finally { clearTimeout(guard) }`。`hot-reload.ts` 的 + helper 把入参放宽到 `T | PromiseLike`(Plugin 契约允许同步 `destroy()`);`engine.ts` 的不放宽 + (`NodeExecutor.execute` 声明返回 `Promise`)。 + + **为什么是 `clearTimeout` 而不是 `unref()`。** `unref()` 让定时器不再钉住事件循环的同时,也让它 + 不再是一个守卫 —— 若被守护的一方永不 settle 且没有别的东西撑着事件循环,Node 会在定时器触发之前 + 退出,超时被静默吞掉。守卫必须在 race 未决期间保持 ref'd、在落定那一刻被回收,这正是 + `finally { clearTimeout(guard) }` 表达的语义。两处的回归测试各自沿用 #4950 的双向写法: + 真实定时器下不留 ref'd 定时器、fake timers 下连跑多轮不累积(计数能看见 `unref()` 过的定时器, + 因此识破 `unref()` 式的假修复)、以及被守护方真的挂住时超时照常上报。 + + 超时时长(`timeoutMs` / `shutdownTimeout`)一个都没动 —— 问题从来不在时长,而在没人回收。 + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/formula@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index a357cc258e..db8af91e79 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.2", + "version": "17.0.0-rc.3", "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 7f31877e8c..cd26426be9 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/service-cache +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 55f6f1c461..c78363fbeb 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.2", + "version": "17.0.0-rc.3", "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 78c366514b..988feaad98 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/service-cluster-redis +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/service-cluster@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 6da18d57d2..37f0ed7d55 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.2", + "version": "17.0.0-rc.3", "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 3751343e70..63de07d6b4 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-cluster +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index a956efae65..ec2ceeb975 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.2", + "version": "17.0.0-rc.3", "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 5b7d50d4d0..b4afb3bfde 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/service-external-datasource +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index ccf7f3bb0d..3f989c45cc 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.2", + "version": "17.0.0-rc.3", "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 4b7095ab49..dfe25f6f7d 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/service-i18n +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 53126fc84b..16ee3afc79 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.2", + "version": "17.0.0-rc.3", "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 33bc0844ab..2abaaccedb 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,59 @@ # @objectstack/service-job +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 7687b0416e..e7b1afdb1f 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.2", + "version": "17.0.0-rc.3", "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 d5a3ae7f01..386deb8ff8 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-knowledge +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 9c5047a7fb..63c1889182 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.2", + "version": "17.0.0-rc.3", "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 78d5bf87cc..dfbb62d799 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,59 @@ # @objectstack/service-messaging +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 806aec35ab..c347533f5c 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.2", + "version": "17.0.0-rc.3", "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 361721ed3d..0ae75e7c5c 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/service-package +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/metadata-core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 8224dd9ef9..6de6a06c5f 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.2", + "version": "17.0.0-rc.3", "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 e52af87206..8d21c9ac03 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,179 @@ # @objectstack/service-queue +## 17.0.0-rc.3 + +### Minor Changes + +- e98fb14: fix(service-queue): `sys_job_queue` no longer grows forever — `completed` rows expire on a declared 7-day retention (#5179) + + `DbQueueAdapter` marked a delivered message `status: 'completed'` and then + **nothing ever touched that row again**. `purge()` had zero production callers + (tests only), `purgeFailed()` is a manual dead-letter API, and the object + declared no lifecycle policy at all — so every queue delivery left a permanent + row, which since #5160 means one permanent row per queued email. + + `sys_job_queue` now declares an ADR-0057 policy and the platform + `LifecycleService` enforces it on its existing hourly sweep: + + ```ts + lifecycle: { + class: 'transient', + retention: { maxAge: '7d', onlyWhen: { status: 'completed' } }, + } + ``` + + **Only `completed` rows are swept.** `pending` / `running` are live work, and + `failed` / `dlq` are the dead-letter queue — they exist to wait for a human, so + they are never deleted automatically at any age. `listFailed()` / `replay()` / + `purgeFailed()` remain the only way a dead letter leaves the table. This is + also why the policy is `retention` (age + row filter) rather than a `ttl` on + `completed_at`: TTL has no row filter, and `dlq` rows stamp `completed_at` too. + + **No new configuration, and no new sweeper.** ADR-0057 §3.3 puts one reaper in + the platform rather than one per plugin — the same call the sibling + `sys_job_run` (30d) already makes. Any kernel with a data engine already runs + it, its per-sweep `[lifecycle] sweep: … ~N rows reaped` line now accounts for + this table too, and the window is overridable per environment through the + `lifecycle` settings namespace without touching code. + + **The dedup window is now an enforced invariant, not a coincidence.** Publish + dedups against a terminal row by comparing its `created_at` to + `idempotencyWindowMs` (default 24h), and the reaper cuts off on that same + `created_at` axis — so retention (7d) ≥ dedup window is what keeps "duplicate + publishes inside the window are suppressed" true. `DbQueueAdapter` reads the + declared window (new export `completedRetentionWindowMs()`) and **throws at + construction** if `idempotencyWindowMs` is configured longer than it, instead of + silently degrading into duplicate deliveries days later. If you raise + `idempotencyWindowMs` past 7 days, raise the object's declared retention (or the + `lifecycle` settings override) to match — the error message names both numbers. + + `class: 'transient'` is deliberate: `telemetry`/`event`/`audit` classes + relocate their table to the dedicated `telemetry` datasource wherever one is + registered (ADR-0057 §3.6), and moving a live work queue's storage would be a + migration, not a cleanup. + +### Patch Changes + +- 7c2f7dd: fix(objectql,service-queue): a `lifecycle` settings override can no longer undercut a consumer's retention floor (#5195) + + ADR-0057 P4 lets an operator override any object's retention window per + environment and per tenant through the `lifecycle` settings namespace. Until now + the only validation on that override was **does it parse** — and a retention + window is not only the operator's business: other code can depend on the rows + still being there. + + `sys_job_queue` is the worked example. `DbQueueAdapter` deduplicates publishes by + comparing a terminal row's `created_at` against its idempotency window, so the + dedup check only means anything while that row still exists; #5179 made the + ordering an invariant by refusing, at construction, an idempotency window longer + than the object's **declared** retention. A settings override the constructor + cannot see walks straight around it: + + ```jsonc + // lifecycle → retention_overrides + { "sys_job_queue": { "maxAge": "1h" } } + ``` + + completed rows are reaped an hour after they are written, publish keeps + deduplicating against 24h, and duplicate deliveries resume **with nothing in any + log**. + + **New: retention floors.** A consumer may now declare, at runtime, the shortest + window its own contract survives: + + ```ts + lifecycle.registerRetentionFloor("sys_job_queue", { + policy: "retention", // or 'ttl' + minWindowMs: 24 * 60 * 60 * 1000, + declaredBy: "com.objectstack.service.queue", + consequence: "…what silently breaks below it", + remedy: "…the settings change that makes an override legal", + }); + ``` + + - An override below the floor — **global or tenant-scoped** — is **rejected**, + and the declared window keeps running. Not clamped to the floor: clamping + would enforce a third number written in neither the declaration nor the + settings, and that number would move whenever an unrelated package changed + its floor. Rejection has exactly one fallback, the declaration, which is + already how an unparseable override resolves. + - The rejection is `error`-level and carries both the consequence and the fix, + because what it prevents leaves the system looking entirely healthy. It is + also on the sweep report as `LifecycleSweepReport.floorViolations` — machine- + readable, every sweep. + - A **declared** window below a registered floor is reported the same way and + still enforced: refusing to reap would trade a broken consumer contract for + the unbounded table #5179 just closed. + - Objects with no registered floor are completely unaffected — P4 overrides + behave exactly as before. + + Floors are runtime wiring, not spec surface (the same call ADR-0057's reap-guard + amendment makes), plus a reason of their own: the queue's floor **is** + `DbQueueAdapterOptions.idempotencyWindowMs`, a per-kernel construction option, so + a static key on the object's `lifecycle` block could only ever be a second copy + of it that drifts. No `packages/spec` change. + + `QueueServicePlugin` registers `sys_job_queue`'s floor on `kernel:ready`, + carrying the window the adapter was actually constructed with — so a non-default + `db.idempotencyWindowMs` is covered too. The ordering is now enforced from both + sides: the constructor rejects a too-long `idempotencyWindowMs`, the floor + rejects a too-short `maxAge`. + + New exports from `@objectstack/objectql`: `LifecycleRetentionFloor`, + `LifecycleFloorViolation`, plus `LifecycleService.registerRetentionFloor()`. + `LifecycleLoggerLike` gained an optional `error()` (absent ⇒ falls back to + `warn`), and `LifecycleSweepReport` gained `floorViolations`. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index b0d4034f96..e44c851185 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.2", + "version": "17.0.0-rc.3", "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 432d79156d..71c94f359f 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,59 @@ # @objectstack/service-realtime +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index b895f30192..0ee819b551 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.2", + "version": "17.0.0-rc.3", "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 2601d2eeb2..d36bf02123 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,334 @@ # @objectstack/service-settings +## 17.0.0-rc.3 + +### Minor Changes + +- 586d6f7: feat(auth): `membership_policy` is a platform setting, and sign-up and backfill read one source (#5152) + + **What a new user joins is now configurable at runtime.** ADR-0093's + `membershipPolicy` decides whether a freshly created user is auto-bound to the + deployment's default organization (`auto`) or gets membership only from an + explicit act — creating a workspace, accepting an invitation, an admin adding + them, SSO just-in-time provisioning (`invite-only`). Until now it was settable + **only** as an `AuthPlugin` constructor option, and the AuthPlugin a self-hosted + stack gets is injected by the CLI, which passes no such option and has no env + fallback. Every self-hosted deployment therefore ran `auto`, with no way to say + otherwise. `invite-only` was, in practice, unreachable outside a custom host. + + It is now `auth.membership_policy` in the platform settings — a two-value select + (`auto` / `invite-only`, default `auto`) alongside `signup_enabled`, which it + pairs with: one says whether people may self-register, the other says what they + join when they do. Set it in Setup → Authentication → Membership, or pin it + per-deployment with `OS_AUTH_MEMBERSHIP_POLICY`. It applies **without a + restart** — the existing `settings.subscribe('auth', …)` re-application seam + carries it, the same one the password-policy keys ride. + + **No behaviour changes unless you set it.** Only an _explicit_ value applies; + the manifest's `auto` default is a UI default and never masks a deployment that + configured the policy in code. A stack that sets nothing keeps today's + auto-binding exactly. + + **Bug fix — the two membership paths read one source.** Sign-up (the reconciler + in better-auth's `user.create.after`) read the AuthManager's live config, while + the ADR-0093 D6 backfill of pre-existing member-less users read the plugin's + **constructor options**. Wiring a setting to the first and not the second would + have produced "sign-up honours the new policy, backfill still runs the old one" + — and the backfill binds in **bulk**, so it is the more dangerous half. Both now + resolve the policy through the new `AuthManager.getMembershipPolicy()`, and the + backfill waits for the settings namespace to bind before its first pass (the two + `kernel:ready` hooks fire in registration order, which was the wrong order). + + **An invalid value is rejected, not coerced.** `PUT /api/settings/auth` refuses + a policy outside the declared option table (`invalid_option`, naming the allowed + set). A value arriving from `OS_AUTH_MEMBERSHIP_POLICY` — which bypasses that + validation — is logged at `error` and **ignored**, leaving the deployment's + current policy in force; it is never silently read as `auto`, because that would + leave an operator believing a wall is up while every sign-up is auto-bound. + + New public API on `@objectstack/plugin-auth`: `AuthManager.getMembershipPolicy()`, + plus `MEMBERSHIP_POLICIES` and `isMembershipPolicy()` from `reconcile-membership`. + +- 9c4f174: feat(plugin-email): durable email delivery through `sys_job_queue`, opt-in (#5160) + + `IEmailService.send()` has always delivered **inline**: the SMTP session ran + inside the caller's `await`, and `EmailService`'s retry loop lived in the same + process — so a crash between the attempt and the retry dropped the message with + no trace beyond a `sys_email` row stuck at `queued`. The pieces for a durable + path all existed (`sys_job_queue`, the `DbQueueAdapter`, an `email.send.async` + subscriber) but nothing in the repo ever published to that topic. + + **New: `queueDelivery`.** With it on, `send()` persists the `sys_email` row, + publishes an `email.send.async` job **referencing that row**, and returns + `{ status: 'queued' }` immediately. A worker delivers the row and finalizes it + in place (`sent` + `message_id`, or `failed` + `error`); the queue retries with + exponential backoff (1s → 5min cap) and dead-letters the job when the attempts + run out, so a restart resumes delivery instead of losing it. The `'queued'` + status was already in `EmailDeliveryStatus` — no spec change. + + Three ways to turn it on, all default-off: + + - `new EmailServicePlugin({ queueDelivery: true })` + - `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) on `os serve` + - Settings → Mail → **Durable queue delivery**, hot-applied without a restart + + **One retry budget, not two.** `retries` keeps its meaning — total attempts are + `retries + 1` in both modes. Inline it drives the in-process loop; queued it + becomes the queue's `maxAttempts` and the per-row loop is pinned to one attempt + per delivery. Turning the toggle on changes _where_ a retry happens (durable, + backed off) and never _how many_ happen, so the two layers cannot multiply. + + **Fixed in the same change: the `email.send.async` subscriber inserted a new + `sys_email` row per delivery.** It called `send()` with the message, so a job + the queue retried five times left five rows — four permanently `failed`, none + carrying the real attempt count. It now delivers the referenced row via + `deliverPersistedRow`, so one message is one row and `attempt_count` + accumulates on it. Messages published in the old shape (a bare `SendEmailInput`) + are still accepted and delivered inline for a migration window. + + Boundaries worth knowing before you switch it on: + + - **"Send test email" always sends inline**, in every mode — the button has to + report the provider's own answer (`535 …`), and "queued" is exactly the + non-answer #5087 removed from it. + - **Messages with attachments or custom headers are delivered inline**, because + `sys_email` has no columns for them and a queued copy would arrive stripped. + Queueing them is tracked separately; this ships the loss-free behaviour. + - **A declaration that cannot be honoured fails the boot.** `queueDelivery: true` + from the constructor or `OS_EMAIL_QUEUE_ENABLED` with no durable queue + registered (or with `persist: false`) throws on `kernel:ready`, naming the + fix — the #5132 judgement, applied to durability. The **settings toggle** is + the opposite trade: it logs at `error` and keeps sending inline, because one + save must not stop the mail. + - The kernel's built-in in-memory `queue` fallback does **not** count as a + durable queue: it delivers synchronously with no retry or DLQ, so publishing + to it would report `queued` for a message nothing could ever recover. Mount + `@objectstack/service-queue` over an ObjectQL engine (the `queue` capability + does this on `os serve`) to get the `sys_job_queue`-backed adapter. + + Leaving `queueDelivery` unset keeps today's behaviour byte for byte. + +- 8597a7d: fix(service-settings,plugin-email): the mail provider dropdown lists only providers that actually deliver (#5094) + + **Settings → Mail → Provider** offered `SMTP | SendGrid | Amazon SES | Postmark`. + `@objectstack/plugin-email` has never carried a SendGrid or an SES transport — + `makeTransport` knows `log` / `resend` / `postmark` / `smtp` and nothing else. So + selecting either of the two validated, saved, showed a success toast, and then + delivered no mail at all: the same declared-but-not-delivered gap #5087 closed + for SMTP, one field to the left. + + The same field broke the invariant in the other direction at the same time: + **`resend` has shipped a working transport all along and was not on the list**, + so nobody could pick the one HTTP provider that worked. + + **The dropdown is now `SMTP | Resend | Postmark | None (log only — no real +delivery)` — exactly the set `makeTransport` can build.** No email capability was + removed with SendGrid and SES. Both publish SMTP endpoints, and #5087 shipped a + real `SmtpTransport`, so both are configured today as `smtp`: + + | provider | host | port | credentials | + | :--------- | :---------------------------------- | :--- | :--------------------------------------------------------------------------------- | + | SendGrid | `smtp.sendgrid.net` | 587 | username `apikey`, password = your API key | + | Amazon SES | `email-smtp..amazonaws.com` | 587 | SES **SMTP credentials** (generated in the SES console — not your AWS access keys) | + + The provider field's own description says this, so the migration is in front of + whoever goes looking for the option that disappeared. + + `log` is listed rather than hidden. It is the one option that does not deliver — + but it does not pretend to: the label says so, `LogTransport` still records every + message to `sys_email`, and "Send test email" answers `ok: false` for it. That + gives an operator the deliberate, visible opt-out AGENTS.md asks a degradation to + be, instead of expressing "no outbound mail" as a half-filled SMTP form. It is + also what makes _offered_ and _deliverable_ the same set rather than merely + overlapping — which is the property a test can hold. + + **Already saved `sendgrid` or `ses`? Nothing breaks and nothing goes quiet.** The + stored value outlives the dropdown, so `applyMailSettings` now recognises it + explicitly: the previous transport is kept (a settings row written by an older + release must never fail a boot), and the server logs at `error` with both halves + AGENTS.md requires — the consequence (_no mail is delivered through it_) and the + fix (the SMTP settings above), not a bare "unknown provider". It is checked + _before_ the API-key check, because "set an API key" is the wrong instruction for + a provider that has nothing to hand a key to. "Send test email" refuses the same + way and sends nothing. Switching the provider to `smtp` and saving recovers the + transport without a restart. + + Two smaller corrections in the same field: + + - `api_key` is now shown and required for exactly `resend` and `postmark` + (`provider === 'resend' || provider === 'postmark'`). It was `provider !== +'smtp'`, which only worked because every non-SMTP option happened to be an + HTTP API; `required` is enforced server-side wherever the field is visible, so + that expression would have refused to save "None (log only)" until an API key + it never reads had been typed in. + - The built-in `mail/test` fallback (the one that runs when no email plugin is + mounted) rejects any `provider` outside the manifest's own option list instead + of answering "the form is well-formed". + + **Held by a test, in both directions.** `EMAIL_TRANSPORT_PROVIDERS` is now a + runtime array (the `EmailTransportProvider` union is derived from it), and + `plugin-email`'s `mail-manifest-providers.contract.test.ts` asserts set equality + between it and the manifest's option values, then builds a real transport for + each. Adding an option without a transport fails; adding a transport without an + option fails. `RETIRED_EMAIL_PROVIDERS` / `isEmailTransportProvider` / + `unsupportedProviderFix` are exported alongside it for hosts that surface the + same guidance. + +### Patch Changes + +- 82a06af: fix(service-settings): a settings `select` now rejects values outside its declared `options` (#5131) + + `SettingsService.validatePatch` enforced two of the constraints a settings + manifest declares — `required` and `pattern` — and skipped the third. A + specifier's `options` table never took part in save-time validation, so any + string at all could be written into a dropdown field: + + ```ts + await svc.setMany("mail", { provider: "sendgrid", from_email: "a@b.com" }); // stored + ``` + + Going through the console this was unreachable: the dropdown only ever emits a + value from the table. But `PUT /api/settings/:ns` is an authorizable public + surface, and scripts, migration tools and AI-authored bootstrap code write it + directly — where the bad value was accepted, persisted and read back **in + silence**, leaving every consumer to improvise its own answer for an + enumeration member that does not exist. It was not `mail`-specific: + `storage.adapter`, `sms.provider`, `ai.provider`, `localization.date_format` and + every other `select` behaved the same way. + + This is the API-side gate that #5094 was missing. That change retired + `sendgrid` / `ses` from the `mail` provider table because this server cannot + deliver through them — with no write-side enforcement, the values it had just + retired could be written straight back in the same afternoon. + + **Now:** a `select` / `radio` / `multiselect` value that is not a member of the + declared table is rejected with a `FieldError` whose `code` is `invalid_option` + and whose `constraint` carries the allowed set (`{ allowed: 'smtp, resend, +postmark, log' }`), so a client composes its own message instead of parsing + ours. The enforced set is the spec's own: `SpecifierSchema` already _requires_ a + non-empty `options` on exactly those three types, so declared and enforced name + one list rather than two that can drift. + + Two deliberate limits keep this from breaking workspaces that already carry + drift: + + - **The check is gated on TOUCH**, like `required` and `pattern` before it. A + value that pre-dates the current option table only fails the patch that + writes that key — editing `from_name` is not rejected because a stale + `provider` sits in the store. The opposite rule would lock every workspace + with historical drift out of its own settings page entirely, which is worse + than the gap being closed. Resets (all-null patches) are never blocked. + - **A specifier that declares no option table is left alone.** It cannot say + what is legal, so it stays lenient rather than rejecting every write. + + Values are compared in string form, so an option declared `value: 30` still + matches after a round trip through JSON or a form post. There is no opt-out: a + manifest that needs to accept custom values would declare that explicitly in + the spec, not rely on a tolerant consumer. + +- 41c3b48: feat(plugin-email): real SMTP delivery — `SmtpTransport`, settings hot-swap, and a `mail/test` that actually sends (#5087) + + The **Mail Delivery** settings page has always defaulted to SMTP and offered a + full host / port / TLS / username / password form. Nothing behind it delivered: + `applyMailSettings` treated `provider: 'smtp'` as a no-op ("transport + unchanged"), `mail/test` answered `ok: true, "Configuration looks valid … Wire +@objectstack/plugin-mail for actual delivery"` — a success toast for a message + nobody sent, naming a package that has never existed — and the code pointed + operators at `@objectstack/plugin-mail-smtp`, which is not in this repo or on + npm. A workspace that selected SMTP got a green form, a green test button, and + mail that only ever reached the log and the `sys_email` table. For deployments + in China this left **no** working channel at all: Resend and Postmark are + overseas HTTPS SaaS with unreliable reach and deliverability to QQ / 163 / + enterprise mailboxes, where SMTP is the normal path (Aliyun DirectMail, Tencent + SES, corporate mail servers). + + **`SmtpTransport` now ships in `@objectstack/plugin-email`** (ADR-0012: SMTP in + core, implemented with `nodemailer`). `nodemailer` is a real dependency but is + imported **lazily on the first send**, so deployments that never select SMTP — + and non-Node runtimes — never load `node:net` / `node:tls`. + + Three doors reach it, all sharing one options reader so they cannot drift: + + - **Settings → Mail** (`smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` / + `smtp_password`) hot-swaps the live transport on save, no restart. + - **`os serve`** via `OS_EMAIL_PROVIDER=smtp` plus the new `OS_EMAIL_SMTP_HOST` / + `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` (or `config.email.options`). + - **Constructor**: `new EmailServicePlugin({ provider: 'smtp', providerOptions: +{ host, port, secure, user, password } })`. + + TLS is one toggle with the wire behaviour derived from the port, as providers + document it: on `465` implicit TLS (SMTPS); on any other port a **required** + STARTTLS upgrade, so a server that refuses to upgrade fails the send instead of + leaking credentials over a cleartext socket; `secure: false` connects in the + clear and upgrades only when STARTTLS is offered. + + **Failure is loud everywhere, because a silent fallback is the bug this fixes.** + On the construction path (CLI / plugin options) a `smtp` provider with no host + **throws** and the boot fails — it no longer degrades into a LogTransport that + reports every send as successful. On the settings hot-swap path a save can never + kill a running server, so the previous transport is kept — but the failure is + logged at `error` naming the consequence and the fix, and **`mail/test` now + performs a real delivery** through the settings on screen and reports the SMTP + server's own words (`535 … authentication failed`) instead of a green toast. The + built-in fallback `mail/test` handler (used only when no email plugin is + mounted) answers `ok: false` and says plainly that nothing was sent. + + Nothing to migrate: `log`, `resend` and `postmark` behave exactly as before, and + a deployment that never selects `smtp` is unaffected. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index f3953f6c53..6fc0e82142 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.2", + "version": "17.0.0-rc.3", "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 613ef5cdd9..f7d011dfde 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-sms +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 3f68486298..b362f0a004 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.2", + "version": "17.0.0-rc.3", "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 2bcc80da06..42595fae8b 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/service-storage +## 17.0.0-rc.3 + +### Major Changes + +- 718b229: fix(service-storage)!: a `sys_file` / `sys_upload_session` write that never landed no longer reports success (#5216) + + `StorageMetadataStore` wrapped **all eight** of its `IDataEngine` calls in + `try { … } catch { /* ignore */ }` — no logger, no rethrow, no degradation flag. + Because `if (this.engine)` had already separated "no data engine wired" out, + those catches could only ever fire on a **runtime** failure of an engine that is + wired: a constraint violation, a connection blip, an RLS refusal, a table that + was never migrated. Every one of them was swallowed, and the store returned the + record it had just put in a process-local `Map`. + + The result on `sys_file` — mostly-permanent business truth with compliance value + (#5202) — was the shape AGENTS.md → "Degradation log levels" exists to forbid: + the bytes landed in the storage backend, the metadata row **never existed**, and + `POST /api/v1/storage/upload/presigned` answered `200 { success: true }` with a + `fileId` naming nothing. A read in the same process then found the Map shadow, + so even a self-check looked healthy — until the worker recycled and the + attachment became permanently unaddressable, with not one line of log pointing + at the cause. On `sys_upload_session` the same swallow made multi-worker chunked + uploads die as unexplained stalls instead of a diagnosable error. + + **What changes.** With a data engine wired, the engine is now the only store: + + - **Writes** (`createFile`, `updateFile`, `deleteFile`, `createSession`, + `updateSession`, `deleteSession`) propagate the failure as a new + `StorageMetadataStoreError` instead of returning a value. Nothing is mirrored + into the `Map`, so there is no in-process shadow left behind to make a lost + write look like a landed one. + - **Reads** (`getFile`, `getSession`) distinguish a **miss** from an **outage**. + `findOne` returning nothing is still a miss and still returns `null` (the REST + layer answers 404, unchanged). An engine that _throws_ now propagates: + substituting this process's `Map` for an unreachable engine would dress a + stale or empty local guess up as the persisted answer, which under multiple + workers is a different wrong answer per worker. + - The process-local `Map` is now exactly what the class doc always claimed — + the stand-in for deployments with **no** engine wired (tests, dev). Behaviour + of `new StorageMetadataStore(null)` is unchanged in every respect. + + **Breaking, and where it shows.** No API signature changed; what changed is that + these calls can now reject. Requests that previously received `200` over a lost + write receive `500 INTERNAL` from the existing storage route handlers (they + already wrapped every handler in `catch → sendError(500, 'INTERNAL', …)`, so no + route needed editing), and a read attempted during an engine outage answers + `500` rather than a false `404 FILE_NOT_FOUND`. If you call + `StorageMetadataStore` directly, the six write methods and the two read methods + may now throw `StorageMetadataStoreError` — `error.objectName` + (`sys_file` / `sys_upload_session`), `error.operation` + (`insert` / `update` / `delete` / `findOne`) and `error.cause` (the engine's own + failure) identify it, and `error.message` states the consequence and the fix. + + There is nothing to migrate: no deployment can have been _relying_ on the old + behaviour, because the old behaviour produced no signal to rely on. What a + deployment may newly _see_ is a 500 that was previously an undetected data loss. + `StorageMetadataStoreError` and the `StorageMetadataOperation` type are exported + from `@objectstack/service-storage` for callers that want to tell a metadata + outage apart from any other 500. + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/observability@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 03ecd5e599..c742944581 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.2", + "version": "17.0.0-rc.3", "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 468eab8e87..9fd6e8feb8 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,2119 @@ # @objectstack/spec +## 17.0.0-rc.3 + +### Major Changes + +- 29c6c9d: feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) + + The declarative API-endpoint surface was **zero-execution end to end**, and said nothing + about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack` + accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact. + The execution side never fired once. On a real boot (showcase, 47 plugins) both declared + paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic + 404, because **no route was ever mounted** for a declared path, so the request died at + Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the + metadata service and called `matchEndpoint` on it — a method **no implementation in the + repo has ever provided**. The branch returned "not handled" on every request ever served. + + So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never + mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`, + `inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and + **`authRequired`**, a security semantic that parsed green and gated nothing at all. That + is false compliance, the failure ADR-0049 exists to stop, not debt. + + ## BREAKING — a non-empty `apis:` is now rejected + + Metadata that parsed cleanly before is now **refused at publish/validate**, with the + prescription in the rejection itself: + + ``` + apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, + so a non-empty array is rejected instead of silently accepted (#4936). … + ``` + + **FROM → TO.** `apis: [ …endpoints… ]` → `apis: []` (or delete the key; both are still + accepted, and an empty array is not a special case). To actually serve the route today, + mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server` + route. That is now the only honest path, and the one `examples/app-showcase` uses + (`src/system/server/recalc-endpoint.ts`). + + The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke + point every path runs through — `defineStack`, the metadata plugin's artifact ingestion, + `os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that + forgot to check. + + **The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and + rejected: endpoint shapes are an industry-stable form, so a retirement would only mean + re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript + and stay in the spec; only _authoring them into a stack_ is refused, and only until the + executor lands. Keep them commented next to your stack — that is what the showcase does. + The executor (route mounting + endpoint matching + per-key wiring for + `authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by + **#5040**, which replaces this rejection with real execution. + + ## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939) + + The repo carried a **second**, unrelated declaration shape for "an API endpoint": + `ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that + `createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every + assembly site lived in `packages/core/examples/`, with no registration in + `packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no + such service. The whole family was therefore inert, including + `ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense** + that "the gateway layer automatically validates these permissions" while no gateway read + it. Two declaration shapes, both dead; this retirement converges them on one. + + Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`, + `ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`, + `ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`, + `ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`, + `ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys). + Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`. + Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was + defaulted to `true` and read by nothing, configuring a service that was never composed. + + **FROM → TO.** There is no replacement shape to migrate to, because nothing executed the + old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`, + you were building a value only your own code read — keep it as your own type. Declarative + endpoints have one vocabulary now, `ApiEndpointSchema`. + + `ConflictResolutionStrategy` **survives** the removal and moved to + `@objectstack/spec/api`'s `router.zod` — same name, same four values + (`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two + independent ratchets and is not part of the retired surface. + + ## Also in this change + + - **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted, + along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves + `LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API + removal — but it returned `{ handled: false }` for every call it ever received, so no + caller can observe a behaviour change beyond the missing symbol. Delete the call. + Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing + at runtime with dead code behind it. + - `examples/app-showcase` no longer declares endpoints, and its coverage manifest no + longer claims the capability is `demonstrated` — that entry read "executed by the runtime + dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver + claim Prime Directive #10 forbids. + - The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**, + the live executor card, instead of #4936, which closes with this change. + +- d21c001: feat(spec)!: declarative `apis:` publishes again — the blanket refusal narrows to per-endpoint publish gates, and declared endpoints go LIVE (#5111, #5040 E7) + + ⚠️ **Read this as a security note, not a schema note.** Declarative endpoints + **execute** from protocol 17. Before this release the surface was inert end to + end — nothing mounted a declared `path`, no matcher existed, and every key + including `authRequired` parsed green and gated nothing — which is why #4936 + refused a non-empty `apis:` outright. The #5040 E-series built the executor + (mount seam, endpoint matcher, policy keys, execution targets, mapping keys, + OpenAPI enrichment), so the refusal's premise is gone and keeping it would be + the lie in the other direction. + + ## BREAKING — the refusal narrows, and what passes it is served + + `apis: [ …endpoints… ]` no longer fails wholesale. Each entry is now gated + individually, and **an endpoint that passes the gate is mounted and answers + real requests as soon as the stack is published.** + + **Before you upgrade, review every historical `apis:` block** — including any + you restored, generated from an older doc, or left in place because it was + known to do nothing. Pay particular attention to any entry that explicitly + declares **`authRequired: false`**: the schema default is `true`, so an + _omission_ is safe and needs no review, while an explicit `false` is the only + thing that opens **anonymous** access to that endpoint. ADR-0121 D6 now pairs + it with a mandatory armed rate limit — and "armed" means + `rateLimit: { enabled: true, … }`, because `enabled` defaults to `false`, so a + budget written without it meters nothing. + + ## The gates, each rejecting with its own prescription + + | gate | rejected shape | + | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **namespace** (ADR-0121 D1/D2) | a `path` that is not `/api/v1/apps//`, or a stack that declares `apis:` without an explicit `manifest.namespace` (no derivation from `manifest.id`) | + | **supported subset** | `type: 'script'` / `'proxy'`; an `object_operation` missing `objectParams.object` or `.operation`; a `flow` with an empty `target` | + | **mapping** | any `transform`; an unusable `source`/`target` path (empty, empty segment `a..b`, `__proto__`/`prototype`/`constructor`); two entries whose `target`s collide (same path, or one inside another); `inputMapping` on a `find`/`get`/`delete` operation, which never reads a body | + | **policy** | `authRequired: false` without `rateLimit.enabled === true`; an armed budget with `maxRequests`/`windowMs` ≤ 0; a negative `cacheTtl`; `cacheTtl` on a non-GET method | + | **uniqueness** | two endpoints in one stack claiming the same METHOD + path (one trailing slash trimmed, the matcher's own rule) | + + **FROM → TO.** `path: '/api/v1//thing'` → + `path: '/api/v1/apps//thing'`, with `manifest.namespace` + declared explicitly. `authRequired: false` → either delete the key (the safe + default `true` applies) or keep it **and** add + `rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }`. Every other + key is unchanged: the `ApiEndpoint` vocabulary is frozen — this release adds, + removes and renames nothing on it. The gates are validation logic over the keys + that already existed. + + The runtime keeps its own refusals for a declaration that reached the store + without passing publish (a direct `metadata.register()`), so the two ends agree: + what publish accepts is exactly what the executor serves. + + `normalizeEndpointPath` is now exported from `@objectstack/spec/api` and is the + one canonical form of a declared path — the publish gate and the endpoint + matcher (`@objectstack/metadata`) read the same rule instead of each carrying a + copy, so a stack can never publish a duplicate the matcher would silently + resolve to a single winner. + +- a019e52: fix(spec)!: `composeStacks` 不再静默丢弃顶层键 —— 同值放行、冲突报错、未声明规则必警 (#5005) + + `composeStacks` 从一个空对象开始逐项填充:`manifest`、`i18n`、`objects`,再加一份 + 手工维护的数组白名单。**不在白名单里的顶层键不是"原样保留",而是被删除** —— + 不报错、不告警,消费方看到的 `undefined` 与"作者从没写过"完全无法区分。 + + 组合栈是平台的应用打包/安装承载,所以这份静默一路蔓延到了安全配置: + + | 顶层键 | 谁消费 | 组合后(修复前) | + | :---------------------------------------------------------------------------------- | :-------------------------------------- | :------------------------------ | + | `api`(含 `enforceProjectMembership` 每环境成员 403 闸门) | `objectstack serve` → REST + dispatcher | **丢** | + | `server`(`security.rateLimit` / `trustProxy`,#4910) | `objectstack serve` → 入站限流器 | **丢** | + | `functions`(声明式 hook / action / script 节点按名解析的 handler) | `AppPlugin` 启动绑定 | **丢** | + | `datasourceMapping`、`datasets`、`jobs`、`emailTemplates`、`docs`、`books`、`tiers` | 各自运行时 | **丢**(声明为数组,却漏进白名单) | + | `runtimeModule` | 构建产物的 ESM handler bundle | **丢** | + + `stacks.length === 1` 时 `composeStacks` 原样返回,所以单栈一切正常 —— 只有真正 + ≥2 个栈才丢,这是它至今没被发现的原因。ADR-0109 当年也只是给 `tools` 单独补了 + 一行白名单,并没有堵住这一类。 + + ## 新语义(维护者 2026-08-04 裁决) + + 1. **同值放行** —— 多个栈声明同一个非数组顶层键且值深相等,照常合成。 + 2. **冲突报错**,错误信息点名冲突键、两个来源栈(manifest id,无 manifest 时用 + `stack #N`)与两条出路(改一致 / 只在应当拥有它的那个栈里保留)。 + ⛔ **不做 last-wins** —— 后组合的包无声关掉前一个栈的 403 闸门或收紧过的限流 + 预算,正是本单要消灭的静默安全降级;⛔ **不做 deep-merge** —— 那会造出一个两 + 位作者都没写过的第三种值。 + 3. **未声明规则的顶层键必警** —— 按默认规则合成(数组拼接,其余按单值规则)**并** + 点名告警指向 #5005,而不是消失。 + + 数组键的拼接语义一字不变。`functions` 按名合并(组合 CRM + Todo 必须两边的 + handler 都在),重名报错而非择一;两种书写形态(map / array)不互转(array 条目 + 带 `packageId`,map 条目没有位置放它),混用报错。`i18n` 保留既有 last-wins —— + 它是这里唯一本来就有明确策略的键,本单主题是"被丢掉的键",不动它。 + + ## 结构性保证 + + 顶层键的处置表类型是 `Record< keyof ObjectStackDefinition, ComposeDisposition >`, + **新增一个顶层键而没说清它怎么合成,`tsc --noEmit` 直接不过**。白名单让"忘记"成为 + 默认,处置表让它成为编译错误;运行时那条 warn 兜住类型看不见的入口 + (`strict: false`、手搓 stack 对象)。 + + ## 破坏性 + + 组合两个对 `api` / `server` / `runtimeModule` 声明了**不同**值的栈,过去静默丢弃、 + 现在抛错;`functions` 重名同理。这正是要的:过去"成功"的那次组合,产出的是一个 + 少了闸门或少了 handler 的栈。改法见错误信息里的处方。 + +- 64fc6d5: refactor(spec)!: remove `connector.rateLimitConfig` and the whole outbound rate-limit shape — the engine never existed (#4911, ADR-0049) + + `ConnectorSchema.rateLimitConfig` let an author declare an outbound throttle for + their connector — `strategy`, `maxRequests`, `windowSeconds`, `burstCapacity`, + `respectUpstreamLimits`, `rateLimitHeaders` — and nothing anywhere applied it. + This is not the ordinary declared-but-unread case; it is a step worse: + **there is no outbound rate-limiting engine to wire it to.** The only token + bucket the platform owns is `packages/runtime/src/security/rate-limit.ts`, and it + is INBOUND — the dispatcher calls `consume(key)` on a request fingerprint and + answers 429. No connector provider (`connector-rest`, `connector-openapi`, + `connector-mcp`, `connector-slack`) reads the key, and no seam exists that could. + + So a well-formed, schema-validated block told the author they had capped their + call rate against a third party's quota, and capped nothing — the false-compliance + class ADR-0049 exists for. With no implementation and no committed roadmap, + `experimental` would be a promise nobody made; **absent** is the honest + disposition. The vocabulary comes back _with_ the engine, in one change + (implementation-first — the #4834 / PR #4878 ruling for the plugin-runtime family). + + FROM → TO: + + | Removed | Replacement | + | :------------------------------------------------------------ | :--------------------------------------------------------------------------- | + | `connector.rateLimitConfig` (key) | **none** — delete it; throttle at the connector provider or upstream gateway | + | `ConnectorRateLimitConfigSchema` / `ConnectorRateLimitConfig` | **none** — importing either is TS2305 in v17 | + | `RateLimitStrategySchema` / `RateLimitStrategy` | **none** — the enum had no other consumer | + + **Do NOT substitute `shared`'s `RateLimitConfig`.** That is the INBOUND limiter + (`enabled` / `windowMs` / `maxRequests`) and caps the calls others make to _us_ — + the opposite direction. #4684 split the two names for exactly this confusion; the + conversion deliberately does not rewrite one into the other, because that would + silently change behaviour rather than losing a no-op. + + The retirement kit: + + - **Tombstone.** `ConnectorSchema` is not `.strict()`, so a plain delete would be + a silent strip (ADR-0104). `retiredKey()` makes the removal audible in the two + channels an upgrading author hits — `tsc` (the key types `never`) and the parse + (the prescription itself). It reaches `stack.connectors[]` and + `DeclarativeConnectorEntry`, which is `ConnectorSchema.superRefine(…)`. + - **ADR-0087 D2 conversion + D3 chain step** (`connector-rate-limit-config-removed`, + `retiredFromLoadPath`): `os migrate meta --from 16` deletes the key from author + sources and stored rows replay clean. A lossless delete — the block never had an + effect to lose. + - **The shape goes with the key.** `ConnectorRateLimitConfigSchema` and the + `RateLimitStrategySchema` enum it embedded had no other consumer, and an + exported schema with no consumer reads as a capability to whoever finds it + (#3950). + - **#4684's rename is absorbed.** `integration/RateLimitConfig` → + `integration/ConnectorRateLimitConfig` and this retirement landed in the same + unreleased major; composed they are a plain delete, so the `RENAMED_DEFS` entry + is removed rather than pointing at a def this build no longer emits. + - Baselines updated deliberately: `json-schema.manifest.json` (−2 defs), + `authorable-surface.json` (−6 def lines; `Connector` / + `DeclarativeConnectorEntry` gain `… [RETIRED]`), `api-surface.json` (−4 + exports). `api-surface-signatures.json` is unchanged by construction — it hashes + each `defineX` parameter as TypeScript _prints_ it, a reference + (`z.input`), so key-level narrowing never reaches it. + + No runtime behaviour changes — that impossibility is the reason for the removal. + +- eaaf03c: refactor(spec,lint)!: retire the dashboard widget action trio + `aria` — and the build gate that enforced a button nobody renders (#5010, ADR-0049) + + `DashboardWidgetSchema` let an author declare a per-widget action **button** + (`actionUrl` / `actionType` / `actionIcon`) and per-widget ARIA attributes + (`aria`). None of the four reached a renderer. Re-measured 2026-08-04 across both + repos on a closed call graph: + + - **the action trio** — all 14 `actionUrl` reads in objectui's + `DashboardRenderer.tsx` are scoped to `schema.header.actions[]`, which is + `DashboardHeaderAction`, a _different_ schema. Nothing anywhere reads + `widget.actionUrl`. `actionIcon` is the starkest: zero references in either + repo outside its own declaration — not even the lint looked at it. + - **`aria`** — no consumer of `widget.aria` anywhere. The `aria-*` attributes in + `DashboardRenderer` / `DatasetWidget` are the renderer's own DOM attributes, + and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a + **view**'s. This is the dashboard-level `aria` that #3896 removed, one level + down — an accessibility guarantee an author could declare and nothing honoured. + + These four survived the #3896 sweep for the same reason `widgets[].responsive` + did, and it is not "we looked and they were live": the liveness ledger declared + no `children` on `dashboard.widgets`, so **no widget-level key had ever been + classified**. #4956 fixed that instrument and gave all 22 keys their first per-key + verdicts; this change acts on four of the six it found dead. + + ## The second-order cost this settles + + `packages/lint`'s `validate-dashboard-action-refs` enforced **ERROR-severity** + reference integrity on `widgets[].actionUrl` — a dangling target failed the + build. Its docblock called the key _"the per-widget button"_ and claimed to + mirror the objectui runtime dispatch. It did not, because that button does not + exist. So an author could be blocked from shipping because a control that cannot + render pointed at an action that also did not. + + A rule written to delete false affordances was sustaining one. That is why the + keys were retired rather than the check merely relaxed: the widget branch is + deleted, with a pin test asserting it stays silent and a second pin proving + header actions are still checked in the same stack. + + FROM → TO: + + | Removed | Replacement | + | :------------------------------- | :----------------------------------------------------------------------------------------------------- | + | `dashboard.widgets[].actionUrl` | `dashboard.header.actions[].actionUrl` | + | `dashboard.widgets[].actionType` | `dashboard.header.actions[].actionType` | + | `dashboard.widgets[].actionIcon` | `dashboard.header.actions[].icon` (the header spelling) | + | `dashboard.widgets[].aria` | **none** — delete it; author `title`/`description`, which the renderer really does label the card with | + + For a per-**row** affordance, reach for a dataset-bound `table`/`pivot` widget: + its rows are clickable and drill through the semantic layer already (no + per-widget drill config exists, by design — #5022). + + **The `AriaProps` shape is NOT removed — only this embed.** `AriaPropsSchema` / + `AriaProps` stay exported and stay live on `app.aria` and + `page.components[].aria`. Nothing importing the shape breaks. + + The retirement kit: + + - **Tombstones.** `retiredKey()` on all four, matching `responsive` in this same + schema. `DashboardWidgetSchema` _is_ `.strict()`, so a plain delete would still + be loud — but only as a generic "unrecognized key". The tombstone keeps the key + declared so the rejection carries the **prescription**, and types it `never` so + authoring it fails `tsc` first. Pins assert the message _is_ the prescription + and is _not_ `Unrecognized key`. The action trio shares one prescription that + names all three, so an author who deletes the one key they were told about does + not hit the same error twice more. + - **ADR-0087 D2 conversion + D3 chain step** + (`dashboard-widget-action-aria-removed`, `retiredFromLoadPath`): + `os migrate meta --from 16` strips the four from author sources, and stored + dashboards replay clean instead of meeting a tombstone at load. Lossless + deletes — none of the keys had an effect to lose. Its own entry rather than + more keys on `dashboard-inert-keys-removed`, whose identity is the #3896 sweep. + - **Liveness rows stay** (`status: dead`, `verifiedAt`, a REMOVED note) because + a tombstone keeps the key in the walked shape — the `rls.priority` precedent. + `authorWarn`/`authorHint` are dropped from all four: the parse owns them now. + - Baselines moved at KEY level only, as the shape's survival implies: + `authorable-surface.json` gains four `… [RETIRED]` lines; + `json-schema.manifest.json`, `api-surface.json` and + `api-surface-signatures.json` are unchanged by construction — no def stopped + being emitted and no export was removed. + + No runtime behaviour changes — that impossibility is the reason for the removal. + The one behaviour that _does_ change is a build that used to fail and now does + not. + + ## Not in this change + + `widgets[].colorVariant`, the fifth dead key #5010 lists, is **deliberately + untouched**. The rewrite target its triage assumed — `options.colorVariant` — + measured dead as well: `options` only reaches a renderer through the inline + `componentSchema` path, and `dataset` is _required_ on this schema, so every + spec-authorable widget is dataset-bound and renders through `DatasetWidget`, + which has no colour affordance at all. Moving the key would relocate 16 authored + sites (7 in `platform-objects`, 9 in `app-showcase`) from one dead slot to + another and mint a second inert key. Returned for adjudication. + +- d17df80: **BREAKING — `dashboard.widgets[].compareTo` converges on the analytics executor's contract (#5011).** + + The widget declared three period-over-period arms with confident TSDoc. The analytics + executor implements one shape, and it was never the same one — so on the ADR-0021 dataset + path (the spec's own "single author-facing analytics shape") **all three arms were + broken**, in two different ways: + + - `compareTo: 'previousPeriod'` / `'previousYear'` were **silently DROPPED** by the dataset + renderer. The widget rendered its base numbers and the comparison the author asked for + simply was not there. + - `compareTo: { offset: '7d' }` was forwarded into `DatasetSelection.compareTo`, whose + contract is `{ kind, dimension }` and has no `offset` in it — so the executor threw + `compareTo requires a timeDimension "undefined"` and the whole widget errored out. + + All three worked on the legacy inline chart path. Same key, two fates, and the failing one + was the path the spec calls canonical. + + `compareTo` is now a thin projection of the contract that is actually implemented: + + ```ts + compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension?: string } + ``` + + There is no widget-side vocabulary left to drift from the executor's, so `declared = +enforced` holds by construction rather than by review. + + ## FROM → TO + + | v16 | v17 | Fix | + | :----------------------------------------- | :-------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------ | + | `compareTo: 'previousPeriod'` | `compareTo: { kind: 'previousPeriod' }` | `os migrate meta --from 16` rewrites it | + | `compareTo: 'previousYear'` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it | + | `compareTo: { offset: '1y' }` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it — `1y` **is** `previousYear` | + | `compareTo: { offset: '7d' \| '1M' \| … }` | **no faithful target** | State the window on the widget's own `filter` and compare with `{ kind: 'previousPeriod' }`, which shifts by that window's own length | + + The last row is deliberately _not_ rewritten. `previousPeriod` shifts by the length of + whatever window the filter resolves to, which equals `7d` only when that window happens to + be seven days — a mechanical rewrite would silently change which rows the comparison + column counts, turning a loud failure into a wrong number. It is registered as the + `dashboard-widget-compareto-offset` semantic migration; the schema rejects the key with the + prescription in hand. + + Retired at the schema, so every old spelling is a parse error carrying its own upgrade — + including the bare strings, which are dispatched by value so a _typo_ is still told it is a + typo rather than told it "was removed". + + ## `dimension` is optional — resolved by the executor, not by a renderer + + Omit it and `dataset-executor.ts` resolves it, by its own long-standing criterion (a + `timeDimensions` entry carrying a `dateRange`): + + - exactly one candidate → that one is shifted; + - **zero** → a loud error: a comparison is only defined against a bounded window; + - **two or more** → a loud error **listing the candidates by name**, never a silent + first-wins. Picking `created_at` when the author meant `close_date` produces a comparison + that is _wrong_ rather than _missing_, which is the failure nobody audits. + + This is a producer-side resolution rule, not consumer-side tolerance (Prime Directive + #12): every caller — dashboard widget, report, raw `queryDataset` — gets the same dimension + or the same error, and no renderer is ever in a position to guess one. + + ## Notes + + - `DatasetCompareTo.dimension` is now optional. Callers that always passed it are + unaffected; callers that relied on the old "must be present" typing get a wider type. + - The converged slot is **union-free**. That is not cosmetic: zod collapses a failed union + into one bare `Invalid input`, so curated guidance written inside a union arm never + reaches the author (#5014). This slot's prescriptions are top-level and do. + - objectui's legacy inline chart path adapts separately (objectui#3337), which also deletes + the `DatasetWidget` string-drop workaround this change makes unnecessary. + +- 7d0e7b5: refactor(spec)!: retire `dashboard.widgets[].responsive` — the straggler of the #3896 inert-key sweep (#4876, ADR-0049) + + `DashboardWidgetSchema.responsive` let an author declare per-breakpoint layout + overrides on a dashboard widget — `breakpoint`, `hiddenOn`, `columns`, `order` — + and no renderer ever read them. The value parsed, validated, and then did + nothing: `DashboardRenderer`, `DashboardEditor` and `plugin-designer` name + `responsive` only in comments, and the one genuine per-breakpoint consumer in + objectui (`useResponsiveConfig`) is fed by `page.components[].responsive`, never + by a widget. Re-measured 2026-08-03 across both repos, plus zero authored + instances anywhere in this repo's examples, apps and tests. + + Four days earlier, #3896 retired the **literally same-named** `view.responsive` + on exactly this evidence. This embed survived that sweep for a reason that is + worth stating plainly, because it is not "we looked and it was live": the + liveness ledger declares no `children` on `dashboard.widgets`, and the walk + drills only one level through an explicit `children` — so **no widget-level key + has ever been classified at all** (22 of them). The instrument had a hole, not + the key a mandate. That gap is filed and fixed separately as **#4956**. + + Leaving it would have shipped v17 with one word and two fates — `view.responsive` + a `tsc` error, `dashboard.widgets[].responsive` silently accepted — which no + author or authoring agent could be expected to explain, on a key that today + accepts _any_ content on both sides (objectui types it a documented `any`). That + is precisely where AI-authored metadata errors hide and multiply. + + FROM → TO: + + | Removed | Replacement | + | :------------------------------------- | :--------------------------------------------------------------------------------------------------------------- | + | `dashboard.widgets[].responsive` (key) | **none** — delete it; the grid reflows by `columns` + `gap` on the dashboard and the `layout` box on each widget | + + **The shape is NOT removed — only this embed.** `ResponsiveConfigSchema` / + `ResponsiveConfig` stay exported and stay live on `page.components[].responsive`, + whose renderer genuinely reads them. Nothing that imports the shape breaks, and + an author who needs breakpoint behaviour today has a real place to put it. This + narrowness is deliberate: the maintainer's ruling covers the dashboard widget + surface only. + + The retirement kit: + + - **Tombstone.** `retiredKey()` on the widget key. `DashboardWidgetSchema` _is_ + `.strict()`, so a plain delete would still be loud — but only as a generic + "unrecognized key". The tombstone keeps the key declared so the rejection + carries the **prescription**, and types the key `never` so authoring it fails + `tsc` first. A pin asserts the message is the prescription and _not_ + `Unrecognized key`. + - **ADR-0087 D2 conversion + D3 chain step** + (`dashboard-widget-responsive-removed`, `retiredFromLoadPath`): + `os migrate meta --from 16` deletes the key from author sources, and stored + dashboards replay clean instead of meeting the tombstone at load. A lossless + delete — the key never had an effect to lose. Kept as its own entry rather than + folded into `dashboard-inert-keys-removed`, whose identity is the #3896 sweep: + this removal rests on its own 2026-08-03 measurement and should say so in + `spec-changes.json` and the upgrade guide. + - **No liveness row is added**, matching `widgets[].performance` in the #3896 + sweep — a widget-level row would be an ORPHAN, not a classification, until + #4956 lands the drill. The ledger `_note` records the removal and why the row + is absent. + - Baselines moved at KEY level only, as the shape's survival implies: + `authorable-surface.json` gains `ui/DashboardWidget:responsive [RETIRED]`; + `json-schema.manifest.json`, `api-surface.json` and + `api-surface-signatures.json` are unchanged by construction — no def stopped + being emitted and no export was removed. + + No runtime behaviour changes — that impossibility is the reason for the removal. + + **objectui shard:** the outcome is retirement, not the fallback clause, so + objectui#3235's conditional pin-bump item is permanently de-listed; the `any` + declaration on that repo's side can be cleaned on its own schedule. + +- 175d789: refactor(spec)!: retire `HttpServerConfigSchema` — nine documented keys with zero readers AND no way to write them (#4938) + + `system/http-server.zod.ts` declared `HttpServerConfigSchema` with nine keys — + `port`, `host`, `cors`, `requestTimeout`, `bodyLimit`, `compression`, + `security`, `static`, `trustProxy`. `authorable-surface.json` listed all nine + and `content/docs/references/` rendered them as protocol documentation. Both + halves of the contract were empty: + + - **Zero runtime readers.** No package in any repo (objectstack / cloud / + objectui) ever parsed a document with this schema or read a key off it. The + only non-spec mentions were "Used by:" comments in `shared/http.zod.ts` + pointing back at it. + - **Zero authoring entry** — worse than the ordinary declared-but-unread + defect. `stack.zod.ts` had no `server:` key, `config-schema.json` had no + `HttpServerConfig`, and no settings manifest carried it, so the configuration + the docs promised could not even be written down, let alone take effect. + + What actually decides these things is three _other_ shapes: the CLI `serve` + arguments, the Hono adapter's `ObjectStackHonoOptions`, and + `DispatcherPluginConfig.securityHeaders`. `HttpServerConfigSchema` was + unacquainted with all three. Per ADR-0049 enforce-or-remove, and the 2026-08-04 + ruling on #4938, the unreachable face is removed. + + FROM → TO, per retired key: + + | removed | what to do instead | + | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ | + | `HttpServerConfig.port` / `.host` | the deployment owns the socket — `objectstack serve -p ` / `PORT` | + | `HttpServerConfig.static` | the transport plugin's `staticMounts` | + | `HttpServerConfig.cors` | the transport adapter — `OS_CORS_ORIGIN` / `OS_CORS_CREDENTIALS` / `OS_CORS_MAX_AGE` | + | `HttpServerConfig.security.helmet` | the dispatcher plugin's `securityHeaders` (on by default) | + | `HttpServerConfig.security.rateLimit` | `defineStack({ server: { security: { rateLimit } } })` — LIVE since #5006 | + | `HttpServerConfig.trustProxy` | `defineStack({ server: { trustProxy } })` — LIVE since #5006 | + | `HttpServerConfig.requestTimeout` / `.bodyLimit` / `.compression` | nothing consumes them; they return with an executor or not at all | + + Two of the nine were **activated** rather than lost: #5006 mounted + `security.rateLimit` and `trustProxy` on the deliberately narrow + `StackServerConfigSchema`, which grows one key at a time, each arriving with its + consumer. `cors` is registered as the FIRST per-key admission candidate for that + shape — embedding (`example-embed-objectql`) is a real scenario — and will + arrive the #4910 way, key and executor together, rather than sitting on the + export surface as a dead declaration in the meantime. + + The retirement kit: + + - **No `retiredKey()` tombstone, deliberately** — route 3 of the retirement + playbook ("nothing parses it"), the shape #4834 / PR #4878 used for the kernel + plugin-runtime family. A tombstone is a message to whoever writes the key, and + the only surface on which anyone can write a server key is + `StackServerConfigSchema`; it is `strictObject` and already rejects all seven + by name. Those prescriptions were refreshed from "not authorable — no runtime + reads it" to name the retirement and its replacement. + - **No ADR-0087 D2 conversion**, for the same reason: there is no author source + to rewrite, because the shape was never reachable from an authoring surface. + The channel for code consumers is `api-surface.json` (which lost all three + `HttpServerConfig*` entries) feeding the release-time `spec-changes.json` + diff, plus this changeset. + - Baselines updated deliberately: `json-schema.manifest.json` (−1, the #2978 + ratchet fired first and demanded it), `authorable-surface.json` (−9, allowed + by the #4650 gate's path 3 "def no longer emitted by this build"), + `api-surface.json` (−3). Reference docs and the strictness-ledger counts + regenerated. + - **The container, not the file.** `RouteHandlerMetadata` (consumed by + `packages/rest`) and `MiddlewareType` / `MiddlewareConfig` (consumed by + `packages/runtime`) stay, as do `CorsConfigSchema`, `RateLimitConfigSchema` + and `StaticMountSchema` in `shared/http.zod.ts` — each has live consumers + outside the retired shape, so none of them was orphaned by it. + + No runtime behaviour changes — that impossibility is the reason for the removal. + +- 26e1029: 退役 `NotificationActionSchema` / `EmbedConfigSchema`——两个从来没有授权门的 `./ui` 词汇表形状(ADR-0049 enforce-or-remove,#5015) + + ## FROM → TO + + | 移除 | 改为 | + | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `NotificationActionSchema` / `NotificationAction`(`@objectstack/spec/ui`) | **无替代形状**。删掉 import 和值。通知的呈现词汇仍在:`NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` 三个枚举原样保留 | + | `EmbedConfigSchema` / `EmbedConfig`(`@objectstack/spec/ui`) | **无替代形状**。删掉 import 和值。表单的公开访问由**活门** `FormView.sharing`(`SharingConfigSchema`)授予,该门未受本次变更影响 | + + 一行修复:删除 import 与值本身 —— 没有任何一份元数据源码需要改写,因为这两个形状**从来就没有键可以写进去**。升级后再 import 是 TS2305。 + + ## 为什么是移除而不是收紧 + + 这一档比「声明了但没人读」还要低一级:**连键都没有**。#4001 批 14 在 2026-08-03 对这两个形状做了三条独立测量,本次退役在 `origin/main` 上把三条全部重跑,每条都带同一次运行内通过的阳性对照: + + 1. **承载键** —— `packages/spec/src` 里没有任何 schema 声明这两个类型的键。`ui/notification.zod` 的非测试 importer 只有 barrel;`ui/sharing.zod` 的是 barrel 加 `ui/view.zod.ts`,而后者点名的是它的**兄弟** `SharingConfigSchema`。匹配按 specifier **解析**而非子串比对 —— 仓里有两个 `sharing.zod`,子串法会把 `stack.zod.ts` 误记为 UI 那个的 importer。 + 2. **图可达性** —— 从 24 个 metadata-type root 加 `defineStack` 的 `ObjectStackSchema` 做 BFS(`build-schemas.ts` 自己的走法,含 derived-clone 桥接)两个都走不到,而 `Page` / `Action` / `DashboardWidget` / `Webhook` 以及 `SharingConfig` 本身在同一次运行里全部 `root-graph`;注入一个合成承载键后两个都翻成 `root-graph`。 + 3. **调用点** —— objectstack / cloud / objectui 三个仓里,除各自单测外零 `.parse()`。 + + 所以没人写得进去,也从来没有东西校验过它们:这正是 #3950 记录的形状 —— 一个没有消费者的导出 schema 会被当成能力来读,而 ADR-0033 的 AI 作者会把 `EmbedConfigSchema` 出现在发布包里当作「平台支持 iframe 嵌入」的证据。 + + 批 14 **故意没有**用 `.strict()` 收紧它们:strict 是一次 **parse** 的属性,对没人 parse 的形状收紧什么也不强制,只会留下*「一个被精确校验的死槽位 —— 更有说服力的谎言」*(#4583)。批 14 把判定挂成 #5015,本次是该判定的执行,裁决 REMOVE(2026-08-04)。 + + 两个形状各自都是**上一层退役留下的孤儿**,这也是它们成为孤儿的原因: + + - `NotificationAction` 在 #4610 失去了两个 wrapper(`NotificationSchema` / `NotificationConfigSchema`,因零消费者被删); + - `EmbedConfig` 在 17.0.0 失去了它的键 —— 2026-06 liveness audit 退役了 `App.embed`(从来没有 iframe 路由读过它),该键至今作为 `retiredKey()` 墓碑立在 `app.zod.ts`。也就是说**写了那个键的作者早就会撞到处方**,本次删掉的是比键活得更久的值形状。 + + ## ⚠️ 范围:按 SCHEMA 退役,不是按文件 + + 两个模块都**存活**,并且都保留活导出: + + - `ui/sharing.zod` 保留 `SharingConfigSchema` —— 这是一个**活门**:`FormViewSchema.sharing` 承载它,`rest-server.ts` 真的读 `sharing.allowAnonymous` / `sharing.publicLink` 来挂匿名表单路由,两个示例应用都在写。**公开表单分享不受影响**; + - `ui/notification.zod` 保留三个呈现枚举。 + + `packages/spec/src/ui/notification-embed-retirement.test.ts` 把两侧都钉住:缺席按 resolved symbol identity across 每个 public entry 断言,**存活侧同样是承重的** —— 一次连文件一起删掉的「退役」能满足全部缺席断言,却会摧毁正在工作的面。 + + ## 运行时行为 + + 字节级不变:从来没有一个 notification action 被从元数据里解析出来,也从来没有 iframe 路由读过 embed config。 + + ## 生成物基线的删除是有意的 + + `json-schema.manifest.json` 少 2 个 key,`authorable-surface.json` 少 10 行,`api-surface.json` 少 4 个导出。这是**整 def 删除**路线的预期读数(而非枚举值收窄那种四张 ratchet 全无变化的形态):`#2978` manifest ratchet 先开火要求有意删除 manifest key,删完重跑后 per-key ratchet 自行判定为 #4650 路径 3(`def no longer emitted by this build`)。 + + ## objectui 侧 + + objectui 的 `animation-notification-spec-parity.test.tsx` 把 `NotificationActionSchema.shape.variant` 当**词汇表**读(不是 parse),用来双向 pin 它自己手写的 `NotificationActionButton` 接口。这恰好说明「有消费者」不等于「有授权门」。该 pin 会在 objectui 刷新本依赖时失去 spec 侧锚点,适配在 objectui 侧单独跟进 —— 本次变更不碰 objectui。 + +- 108ba8d: **Object inner blocks now reject unknown keys instead of dropping them (#4001 批 20).** + + Thirteen object shapes nested inside `data/object.zod.ts` were still zod's default + `.strip`: a key the schema did not declare was discarded and the parse still + succeeded. The object's TOP level has rejected unknown keys since #1535/#4519/#4522, + and that asymmetry is what made this the batch worth doing — an author who has _seen_ + the root reject a typo has every reason to read a clean parse of + `lifecycle: { maxAge: '30d' }` as acceptance. `object` carries the highest author + volume in the repo. + + Closed, each reached through its real carrier key and probed there (strictness does + not recurse, so a closed parent proves nothing about a nested block): + + - `access` — the ADR-0066 D2 exposure posture. + - `lifecycle` **and all four sub-blocks** — `retention`, `ttl`, `storage`, `archive`. + - `fieldGroups[]` — the ADR-0085 group entry. + - `external` — the ADR-0015 federated binding. + - `userActions`, `systemFields`, `activityMilestones[]`, `publicSharing`. + - `objectExtensions[]` — the extension entry (`defineObjectExtension`). + + **Migration.** Any key now rejected was previously stripped and had no runtime + effect — the error carries the fix. The dominant real-world mistake on this file is + **flattening**, so `lifecycle` points DOWN into the sub-block that owns each key: + `maxAge` → `retention`, `expireAfter`/`field` → `ttl`, `strategy`/`shards`/`unit` → + `storage`, `after`/`to`/`keep` → `archive`. That one matters beyond tidiness: a + flattened `maxAge` leaves `retention` absent, so ADR-0057 §3.5 then rejected the + object as _unbounded_ — an error naming the wrong key entirely. + + Other wrong-layer pointers, each anchored to a named sibling contract: + `userActions.sort`/`search`/`filter`/`editInline` point at `ui/view.zod.ts`'s + identically-named block, whose vocabulary is completely disjoint from the object's; + `userActions.clone` points at `enable` (ObjectCapabilities); `systemFields.owner` + points at `ownership` — a key the block's own field doc names but the shape never + declared; `external.allowWrites` names the ADR-0015 double opt-in and mirrors + `datasource.zod.ts`'s own `writable → allowWrites` alias in the opposite direction; + `access.sharingModel` and `publicSharing.sharingModel` point up at the top level and + distinguish link sharing from principal sharing; `fieldGroups[].fields` states the + direction of the membership edge (declared on the FIELD, as `group:`); + `objectExtensions[].actions`/`hooks`/`listViews` say plainly that the merge has no + slot for them and name the route that does. Aliases cover the near-misses distance + cannot reach (`export` → `exportCsv`, `audiences` → `allowedAudiences`, `table` → + `remoteName`, `object` → `extend`, …). `fieldGroups[]`'s three DEPRECATED collapse + aliases stay **accepted** — closing a shape must not turn a documented deprecation + into a rejection. + + **`IndexSchema` is deliberately NOT closed, and that hold is the batch's finding.** + The console ships its own hand-copied JSON-Schema for this shape (objectui + `metadata-admin/EmbeddedItemEditor.tsx`), because `index` is an embedded-only + sub-type the framework publishes no schema for — and that copy has drifted: it + offers **`where`** for the partial-index predicate where the spec declares + **`partial`**. The editor splices its output into `object.indexes[]` and PUTs the + whole object, and `saveMetaItem` keeps the body verbatim while validating it, so + closing this one shape would 422 a control the console itself renders (the #5114 + class, caught this time _before_ shipping rather than after). The capability is + already dead in both directions — `driver-sql`'s `syncDeclaredIndexes` reads + `name`/`fields`/`unique` only, so neither spelling reaches any DDL — which is + exactly why the close is gated on both the producer rename and an ADR-0049 answer + for `type`/`partial`: pointing an author at `partial` today would be a guidance + entry claiming more than the platform delivers. + + One caveat shipped knowingly: `systemFields` is a `false | {…}` union, so its + rejection is an `invalid_union` whose own message is the bare _"Invalid input"_ — + the #5014 flattening. 批 18's `discriminatedUnion` fix is unavailable here (one arm + is a literal, so there is no discriminant to key on), so the behaviour is pinned + honestly rather than papered over. Every other site in the file is a plain object + and surfaces its prescription directly. + +- 4845f85: **The retry policy's last two dialects converge** (#4964 `flow.errorHandling`, #4962 + `ETLPipeline.retry`). + + #4661 converged the retry policy onto one declaration. It converged the two shapes that + published the **same exported name** (`RetryPolicy` from `./automation` and `./system` — + the #4411 trap), because that is the question the dual-source instrument asks. Two more + encodings of the identical concept were outside its vision _by construction_: both are + anonymous inline `z.object`s nested in a bigger schema, with no exported name to collide. + + The cost of the gap fell on the author who did the right thing. `shared/retry-policy.zod.ts` + tombstoned `retryDelayMs` and told them to write `backoffMs` — and `flow.errorHandling` + then **rejected** `backoffMs` and demanded `retryDelayMs`. Reading the newer file was + punished, and which file an AI author reads first is arbitrary. + + All four surfaces — `job.retryPolicy`, a `try_catch` node's `retry`, `flow.errorHandling` + and an ETL pipeline's `retry` — now build from one shared shape. + + ## FROM → TO + + ### `flow.errorHandling` (#4964) + + | | FROM | TO | + | ----------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------ | + | base delay | `retryDelayMs`, min 0, default 1000 | **`backoffMs`**, min 0, default 1000 | + | `maxRetries` / `backoffMultiplier` / `maxRetryDelayMs` / `jitter` | _(already identical)_ | unchanged | + | `strategy` | `'fail' \| 'retry' \| 'continue'` | unchanged — it selects _whether_ the policy runs, so it stays outside it | + + One key, one word, no default changes. Every other key, bound and default already + matched the converged policy, which is exactly why the divergence survived a release: + it looked reviewed. + + ### `ETLPipeline.retry` (#4962) + + | | FROM | TO | + | ------------------- | ---------------------------------------------- | ----------------------------------------- | + | count | `maxAttempts`, min 0, **default 3**, unbounded | **`maxRetries`**, 0–**10**, **default 0** | + | base delay | `backoffMs`, default **60000** | `backoffMs`, default **1000** | + | `backoffMultiplier` | _(absent)_ | ≥1, default 1 | + | `maxRetryDelayMs` | _(absent)_ | default 30000 | + | `jitter` | _(absent)_ | default false | + + ## What you must change + + **1. Rename `retryDelayMs` → `backoffMs`** in any `flow.errorHandling` block. The value + (milliseconds before the first retry) is unchanged. The old spelling is **tombstoned**, + not deleted, so it rejects with the rename rather than being silently stripped, and + `os migrate meta --from 16` (the `retry-policy-converged` conversion, now with a + flow-level branch) rewrites it for you. + + **2. Rename `maxAttempts` → `maxRetries`** in any `ETLPipeline.retry` block. **The number + does not change** — both counted the retries _after_ the initial attempt. Do **not** + subtract one: that adjustment belongs to `integration/connector.zod.ts`'s + identically-spelled `RetryConfig.maxAttempts`, which _includes_ the first attempt and is + deliberately **not** part of this convergence. + + **3. If an ETL pipeline relied on the implicit retry count, write it out.** `retry: {}` + used to mean three re-runs 60s apart; it now means **none**. State `maxRetries: 3` (and + `backoffMs: 60000` for the old delay) to keep the old behaviour. + + ## Why the ETL default flips to 0 + + Not merely to follow #4661. An ETL destination is a foreign system _by definition_ — a + warehouse, an API, someone else's database. A silent retry against a non-idempotent + destination is a **duplicate write**: a second invoice, a second export, a second + webhook. Default 0 makes retrying something an author states, and thereby claims + idempotency for. An unstated key is precisely where LLM-authored metadata hides this. + + ## Migration surface + + **`flow.errorHandling`** is live: `service-automation`'s `retryExecution` reads the key + (it now destructures `backoffMs`), and the D2 conversion covers stored and authored + flows, so no deployed stack changes behaviour. + + **`ETLPipeline.retry` has an empty migration surface today, and that is why now was the + moment.** `etl.zod.ts` has no parse site in objectstack / objectui / cloud (批 12's + measurement) and an ETL pipeline is not a `defineStack` collection, so there is no stored + document a conversion could walk — it deliberately gets a tombstone and **no** D2 step, + rather than a walker advertising coverage that does not exist. Once an ETL engine lands, + flipping this default stops being a schema edit and becomes a behaviour change to every + deployed pipeline. + + ## Also + + The two automation retry surfaces now carry the **same** curated unknown-key table, so an + author learns one lesson instead of two, and `retry-policy.test.ts` gains a + concept-level guard: all four surfaces are asserted to expose the same key set and the + same defaults, by parse rather than by inspecting how each obtains them. Adding a fifth + retry surface without wiring it to the shared shape now fails a test — which is the check + that would have caught both of these issues, and the one the name-based scan could never be. + +- f61c8cf: feat(spec,metadata-protocol)!: a sort node spelling its direction `direction` is a 400, not a silently reversed page (#4721) + + **FROM → TO:** `orderBy: [{ field: 'updated_at', direction: 'desc' }]` → + `orderBy: [{ field: 'updated_at', order: 'desc' }]`. One word. If you are on the + `{field, direction}` shape because you moved code over from + `IReportService.orderBy`, that contract is unchanged — it is `orderBy` on the + QueryAST / `EngineQueryOptions` axis that has always been `{field, order}`. + + ## What was wrong + + `SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied. + Measured on `main` before this change: + + ``` + SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' }) + → { field: 'updated_at', order: 'asc' } + ``` + + `direction` was discarded and `order` fell back to its `asc` default. The sort + therefore ran in the **opposite** direction and the request succeeded. Paired + with `limit` — which is how a caller asks for "the latest N" — that is not a + reordered page but a **different set of rows**, returned under an ordinary 200 + with nothing in the response to distinguish it from the answer that was asked + for. + + `direction` is not a typo. It is the live vocabulary of a neighbouring contract, + `IReportService.orderBy` (`@objectstack/spec/contracts`), and + `plugin-auth/objectql-adapter.ts` already translates between the two by hand — a + translation known to be necessary and enforced nowhere, which is the ADR-0049 + shape. + + ## What changed + + Both doors onto that shape, in one change: + + 1. **`SortNodeSchema`** (`spec/src/data/query.zod.ts`) is now `strictObject` + with `aliases: { direction: 'order' }`. An unknown key is rejected, and + `direction` specifically gets the translation in the error message — edit + distance can never bridge `direction` → `order`, so a bare "unrecognized key" + would leave the caller exactly where the silent strip did. + 2. **`normalizeSortNodes`** (`metadata-protocol/src/protocol.ts`), the ingress + every REST/RPC `orderBy` funnels through, refuses `{ field, direction }` with + `400 INVALID_SORT` naming `order` and quoting the corrected node. Closing only + the schema would repeat the door asymmetry of #1535/#4522: `SortNodeSchema` is + reachable by three paths the REST normalizer never sees. + + | `orderBy` you send | Before | After | + | :----------------------------------------------------- | :-------------------------- | :---------------------------------------------------------- | + | `[{ field: 'x', order: 'desc' }]` | descending | unchanged — descending | + | `[{ field: 'x', direction: 'desc' }]` | **200, ascending** | `400 INVALID_SORT`, message names `order` | + | `[{ field: 'x', order: 'desc', direction: 'asc' }]` | 200, descending | `400 INVALID_SORT` | + | `'-x'` / `['-x']` / `{ x: 'desc' }` | descending | unchanged | + | `{ direction: 'desc' }` (the `{field: direction}` map) | sorts by column `direction` | unchanged — a column may legitimately be called `direction` | + + Scope is deliberately narrow: **`QuerySchema`'s top level is untouched** and + still accepts undeclared keys (`QuerySchema.safeParse({ object: 'sales', +nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map + for its own batch, not smuggled in here. + + Related: #4674, #4720, #4363, #4371, #4001, ADR-0049. + +- 5278e11: **BREAKING** — `automation/control-flow` and `automation/state-machine` reject unknown keys (#4001 批 10, ADR-0078) + + Eleven authoring shapes that silently discarded undeclared keys now refuse them with a + named surface, the offending key echoed back, and a rename or prescription. Metadata that + used to parse "successfully" while losing the key you wrote now returns 422. + + **`automation/control-flow.zod.ts`** — `FlowRegionSchema`, `LoopConfigSchema`, + `ParallelBranchSchema`, `ParallelConfigSchema`, `TryCatchConfigSchema`. + + **`automation/state-machine.zod.ts`** — `ActionRefSchema` (object branch), + `GuardRefSchema` (object branch), `TransitionSchema`, `StateNodeSchema`, its `meta` block, + and `StateMachineSchema`. + + ## What was actually being lost + + A `state_machine` on an agent's `lifecycle` with `onn` where `on` was meant parsed clean + and came back with **no transitions at all** — the declaration whose entire purpose is to + deny undeclared transitions, silently emptied and reported valid. A `loop` config with + `maxIteration` (singular) came back uncapped. A `parallel` branch with `label` instead of + `name` came back unnamed. + + ## Migration — FROM → TO + + Renames the rejection now suggests for you: + + | you wrote | write instead | on | + | ------------------------ | ------------------ | ---------------------------------------------------------------------------------- | + | `guard` | `cond` | a state transition (XState v5 renamed it the other way; this protocol kept `cond`) | + | `action` | `actions` | a state transition | + | `itemVariable` | `iteratorVariable` | a `loop` config | + | `maxIteration` | `maxIterations` | a `loop` config | + | `label` | `name` | a `parallel` branch | + | `onn` / `entery` / typos | `on` / `entry` | a state node | + + Keys with no replacement, and what to do instead: + + - **`finally` on `try_catch`** — there is no `finally` region. The node's ordinary + out-edges run whichever way the protected region went; put the always-run steps in the + nodes **after** the container. + - **`join` / `joinGateway` on `parallel`** — the join is implicit; the block continues once + when every branch completes. `join_gateway` is a BPMN interop node type, never a + `parallel` config key. + - **`flowName` on `loop`** — that key belongs to the `map` node, which runs a subflow per + item. A `loop` runs an inline region: move the steps into `config.body`, or change the + node `type` to `map`. + - **`name` / `label` on a region** — a `loop` body, a `try` region and a `catch` region are + not named; only a `parallel` branch carries a `name`. + - **`transitions` on a state node** — a state node declares transitions as `on`, keyed by + event type. `transitions` is the key on the object-level `state_machine` **validation + rule** (`validations[].transitions`), a different declaration. + - **`context` on a state machine** — this protocol declares only the context SHAPE, as + `contextSchema`. There is no key for seeding initial values, so the two are not a rename + of each other. + + ## Two notes for upgraders + + `ActionRef` / `GuardRef` are unions, so a rejected key on their object branch surfaces as + zod's `invalid_union` (`"Invalid input"`) with the real prescription nested one level down + in `issue.errors[]` rather than in the top-level message. The prescription is present in + `ZodError.message` and in REST error bodies; single-line formatters drop it. + + `StateNodeSchema.meta` is **closed**, not a passthrough bag. XState treats `meta` as open, + but the hand-written `StateNodeConfig` type here declares exactly `label` / `description` / + `color` / `aiInstructions`, nothing in the platform reads any other key, and the previous + behaviour was not openness but strip — an authored `meta` arrived as `{}`. + + All three example apps (`app-showcase`, `app-crm`, `app-todo`) validate unchanged, so no + ADR-0087 conversion accompanies this change. + +- f8cfbb4: **BREAKING (theme):** retire the nine theme token groups that were emitted and read by nobody (#5021, ADR-0049 enforce-or-remove). + + `ThemeSchema` declared a full design-token vocabulary — a type scale, a weight + scale, line-height and letter-spacing scales, a motion scale and a z-index scale. + objectui's theme engine turned every one of them into CSS custom properties, + faithfully and for years. What never existed was a **reader**: measured against + objectui `main` on 2026-08-04, `--font-size-*`, `--font-weight-*`, + `--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, + `--font-heading` and `--font-mono` have **zero** consumers across objectui's + components and stylesheets, while `--font-sans`, `--radius*`, `--shadow*` and the + colour variables come back live in the same run. So a declared type scale was + real CSS that styled nothing, and an overlay you "lifted" with `zIndex` still + stacked by document order. + + This is why the earlier theme sweep (#3494) left them standing: its criterion was + _"the engine never emits it"_, and these are emitted. ADR-0049's criterion — + emitted, but consumed by nobody — is what reaches them. + + FROM → TO: + + | Removed | Replace with | + | ------------------------------------- | ----------------------------------------------------------------------- | + | `theme.typography.fontSize` | `theme.customVars: { "font-size-lg": "1.125rem" }` | + | `theme.typography.fontWeight` | `theme.customVars: { "font-weight-semibold": "600" }` | + | `theme.typography.lineHeight` | `theme.customVars: { "line-height-relaxed": "1.75" }` | + | `theme.typography.letterSpacing` | `theme.customVars: { "letter-spacing-wide": "0.025em" }` | + | `theme.typography.fontFamily.heading` | `theme.customVars: { "font-heading": "Georgia, serif" }` | + | `theme.typography.fontFamily.mono` | `theme.customVars: { "font-mono": "ui-monospace, monospace" }` | + | `theme.animation` | `theme.customVars: { "duration-fast": "150ms", "timing-ease": "ease" }` | + | `theme.zIndex` | `theme.customVars: { "z-modal": "1050" }` | + + The one-line fix: **delete the key; re-declare under `customVars` only the + variables your own stylesheets actually read.** `customVars` emits each entry + verbatim as `--: `, so every retired variable is reproducible byte + for byte — no capability is lost. Run `os migrate meta --from 16` to strip the + keys automatically; it emits one notice per key so you can see what you were + declaring before deciding what to keep. + + `colors`, `borderRadius`, `shadows` and `typography.fontFamily.base` have live + consumers and are **unchanged**. + + The retirement kit: + + - **Schema** — each key is a `retiredKey()` tombstone, so authoring one is both + a `tsc` error (the input type is `never`) and a parse error carrying the + prescription above. `AnimationSchema` and `ZIndexSchema` were deleted outright + along with the `Animation` / `ZIndex` types: each had exactly one consumer — + the key now tombstoned — and an exported schema with no consumer reads as a + capability to whoever finds it (#3950). + - **Aliases** — the five that pointed at `animation`/`zIndex` and the seven that + pointed into the retired typography scales were deleted with their targets + rather than re-pointed. Keeping them would answer an author with _"did you mean + `zIndex`?"_ and then reject `zIndex` — a rename into a second rejection. + - **Migration** — `theme-inert-token-scales-removed` (ADR-0087 D2), wired into + the protocol-17 chain step and retired from the load path, so a live parse + rejects loudly and only `os migrate meta` rewrites sources. It **deletes** the + keys rather than auto-populating `customVars`: a rewrite would hand back two + dozen variables that still nothing reads, turning a dead semantic slot into a + dead literal one. + - **Baselines** — `authorable-surface.json` gains eight `[RETIRED]` markers and + loses the ten `ui/Animation:*` / `ui/ZIndex:*` lines under the #4650 deletion + check's whole-def proof; `json-schema.manifest.json` drops the two defs. + +- c87ef70: Close nine authorable automation shapes against unknown keys (#4001 batch 11, ADR-0078) + + zod's default is `.strip`: a key a schema does not declare is silently discarded + and the parse still succeeds. On an authoring surface that is the worst failure + mode — the author (increasingly, an AI) gets a success envelope and ships + metadata that quietly ignores what they wrote. This batch closes the nine + remaining shapes in `automation/`'s main body. + + **BREAKING.** Each of these now raises a named, fixable error instead of dropping + the key. The rejection carries the surface, the offending key, and — where the + word is recognisable — the canonical spelling. + + **`flow.zod.ts` — the six NESTED blocks.** The four outer shapes (flow / node / + edge / variable) were closed earlier; their inner blocks were not, so the gate + rejected `nodee:` at node level while `connectorConfig: { connectorID }` — one + capital letter — parsed clean and dispatched the action against an undefined + connector id. Now strict: `FlowNode.connectorConfig`, `.position`, + `.inputSchema` (each parameter declaration), `.waitEventConfig`, + `.boundaryConfig`, and `Flow.errorHandling`. + + Renames the rejections offer, each one a real spelling of the same knob on a + neighbouring surface in this repo: + + | you wrote | write instead | where the other word comes from | + | ----------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------- | + | `connectorConfig.params` / `parameters` / `arguments` / `payload` | `input` | script-node `config.inputs`, integration products | + | `waitEventConfig.event` / `signal` / `duration` / `delay` | `eventType` / `signalName` / `timerDuration` | — | + | `boundaryConfig.attachedToRef` / `cancelActivity` | `attachedToNodeId` / `interrupting` | BPMN 2.0's own attribute names | + | `errorHandling.backoffMs` | `retryDelayMs` | `shared/retry-policy.zod.ts` (#4661) | + | `errorHandling.initialDelayMs` / `maxDelayMs` | `retryDelayMs` / `maxRetryDelayMs` | connector `RetryConfig` | + | `errorHandling.retries` / `attempts` / `onError` | `maxRetries` / `strategy` | — | + + Two are prescriptions rather than renames, because a rename would be wrong: + `inputSchema`'s `optional` is the opposite polarity of `required` (write + `required: false`), and `errorHandling.maxAttempts` counts the first attempt + while `maxRetries` counts the ones after it (write `maxRetries: maxAttempts - 1`). + + **Deliberately still open**, both now pinned in code and in tests so a later + sweep stops rather than "finishing" the file: the flow node `config` slot + (ADR-0018 — the plugin node-type namespace, owned by each executor's + `configSchema`) and `FlowVersionHistorySchema` (emitted on publish, never + authored; the flow _inside_ a history record is still gated by `FlowSchema`). + + **`time-relative-trigger.zod.ts`.** `config.timeRelative` sits under the open + node `config` slot, so this schema is the only key gate it has — and it is + `safeParse`d at BIND time, not only at authoring. `{ …valid, offsetDay: 7 }` + used to bind a sweep that ran daily with the author's narrowing discarded, and + reported itself configured; it now refuses to bind and says why. `field` → + `dateField`, `filters` → `filter`, `objectName` → `object`, `limit` → + `maxRecords`; `schedule` and `runAs` get pointed at the layer that owns them. + + **`flow-function.zod.ts`.** `{ handler, effect }` in `defineStack({ functions })`. + This binds at authoring only — the boot path reads entries with + `normalizeFlowFunctionEntry`, not a parse — which is exactly why it matters: + that reader takes two keys and ignores the rest by construction, so a misspelled + `effect` was dropped at the schema and then not looked for. The function still + registered, still ran, and its writes were still counted as none, keeping + #4354's broken-sweep query silent on the one run that needed it. + + **`webhook.zod.ts`.** `object_name` → `object` and `active` → `isActive` (the + `sys_webhook` column names, for anyone re-authoring from a row), `events` → + `triggers`, `endpoint` → `url`; the five props #3494 removed now reject with + their reason instead of vanishing. Strictness also rides `.extend()` onto the + connector `WebhookConfigSchema`. + + **`webhook` also gains the ADR-0010 protection envelope** (`protection`, plus + the loader-set `_lock` / `_lockReason` / `_lockSource` / `_provenance` / + `_packageId` / `_packageVersion` / `_lockDocsUrl`). This is not a separate + feature: both metadata load paths call `applyProtection` on every type, so a + package-loaded webhook already carried those keys when `plugin-webhooks` + re-parsed it at boot. Closing the shape without declaring them would have turned + every package-shipped webhook into a skipped subscription after a redeploy. + +- 3cb0618: feat(spec)!: reject unknown keys on the ETL authoring contracts (#4001 批 12) + + The final `automation/` wave of the 2026-08-03 "necessary-and-complete" ruling. + Seven strip sites in `automation/etl.zod.ts` close, and `automation/`'s + remaining-strip count drops 53 → 46 (authorable 27 → 20). + + Now strict: `ETLSourceSchema` (and its `incremental` block), + `ETLDestinationSchema`, `ETLTransformationSchema`, `ETLPipelineSchema` (and its + `retry` and `notifications` blocks). + + **Deliberately still open: `ETLPipelineRunSchema`, `.stats` and `.error`.** + Every key on those is a fact the engine produces about a run that already + happened — an id it minted, a status it reached, counters it accumulated. + Nobody authors a run result, so strictness buys no author protection there, + while it would turn a future engine reporting one more counter into a parse + crash for every existing reader. Same disposition, same reason, as + `FlowVersionHistorySchema` and all of `execution.zod.ts`. + + **Migration.** Every key now rejected was previously stripped and had no + runtime effect, so removing or renaming one never changes behaviour. No + ADR-0087 conversion is needed: the three shipped example apps' built artifacts + were walked (3930 nodes) with 0 shapes newly rejected, the probe proven red + first on an injected control, and all three `objectstack validate` runs pass. + + The rejections carry their own prescriptions: + + - **source / destination / transformation**: the common mistake here is not a + typo but a MISPLACEMENT. `table`, `schema`, `endpoint`, `path`, `format`, + `condition`, `groupBy` are real settings that belong one level down, inside + the open `config` record; every message on these three surfaces says so. + - `source.incremental`: `timestampField` → `cursorField` (the connector layer's + `DataSyncConfig` spells the same thing `timestampField`). + - `destination`: `strategy` → `writeMode`; on the **pipeline** the same word is + `strategy` → `syncMode`. The connector's one `strategy` enum + (`full | incremental | upsert | append_only`) splits across those two keys — + the write half on the destination, the extraction half on the pipeline. + - pipeline `direction`: not a key at all, by design. An ETL pipeline states + direction structurally, by which endpoint is `source` and which is + `destination`; to reverse one, swap the two endpoints. + - `retry`: `maxRetries` → `maxAttempts`, and `retryDelayMs` → `backoffMs` (the + pre-17 spelling retired in #4661). `backoffMultiplier`, `maxRetryDelayMs` and + `jitter` are declared on the converged `RetryPolicySchema` and **deliberately + absent** here — a documented absence, not a typo. Converging the two retry + vocabularies is tracked as #4962. + - `notifications`: `onError` → `onFailure`. + + **One published-artifact change, not a no-op.** The campaign's standing claim + that strictness does not move the published JSON Schema holds per direction: + `build-schemas.ts` prefers `io: 'output'`, where a stripping object already + emits `additionalProperties: false`. `ETLPipelineSchema` is the case where that + does not apply — it cannot convert in output mode at all (`schedule` is + `CronExpressionInputSchema`, a transform), so the build falls back to input + mode, and there strip emits nothing while strict emits + `additionalProperties: false`. The published pipeline schema therefore narrows + from "unspecified" to "closed", which is the intended direction — the + publication now matches the parse instead of being quieter than it. + `ETLPipelineRun` publishes under output mode and did not move. + +- 32a0874: feat(spec)!: reject unknown keys on the flow-node config contracts (#4001 批 9) + + The first `automation/` wave of the 2026-08-03 "necessary-and-complete" + ruling. Fourteen strip sites across three files close, and `automation/`'s + remaining-strip count drops 67 → 53 (authorable 41 → 27). + + - **`automation/io-node-config.zod.ts`** — `NotifyConfigSchema`, + `HttpConfigSchema`. + - **`automation/builtin-node-config.zod.ts`** — the CRUD quartet + (`get_record` / `create_record` / `update_record` / `delete_record`), + `ScreenConfigSchema`, `ScreenFieldConfigSchema` and its `options` item, + `MapConfigSchema`. + - **`automation/schemaless-node-config.zod.ts`** — `ScriptConfigSchema`, + `SubflowConfigSchema`, `DecisionConfigSchema`, `DecisionConditionSchema`. + + The deliberately-open `FlowNodeSchema.config` SLOT is unchanged — ADR-0018 + keeps `node.type` open so plugins contribute their own executors, and closing + the slot would close that extension point. What is closed is the per-node-type + contract _inside_ it. + + **Why the third file is different.** `registerFlow()` already hard-rejects + undeclared config keys against a node's descriptor `configSchema` (#4277), and + `script` / `subflow` / `decision` publish no descriptor `configSchema` — so + that walk skips them by construction. Until now those three had **no** + unknown-key enforcement at any layer. For them this is the first gate, not a + second one. + + **Migration.** Every key now rejected was previously stripped and had no + runtime effect, so removing or renaming one never changes behaviour. All three + shipped example apps were re-validated after the change and no stored shape + needed an ADR-0087 conversion (160 flow nodes walked, 52 carrying one of these + contracts, 0 rejections). The rejections carry their own prescriptions: + + - `notify`: `to` → `recipients`, `subject` → `title`, `body` → `message`, + `url` → `actionUrl`, `source: { object, id }` → `sourceObject` + `sourceId`. + - CRUD: `object` → `objectName`, `filters` → `filter`, + `fieldValues` → `fields`, `recordId` → a filter VALUE + (`filter: { id: '{record.id}' }` — no CRUD executor has ever read a + `recordId` key), and on `update_record` / `delete_record` `outputVariable` + is a documented absence, not a typo — read the row back with a following + `get_record`. + - `screen`: `object` → `objectName`, and on a field item + `visibleIf` → `visibleWhen`. + - `map` / `subflow`: `flow` → `flowName`. `subflow`'s `timeoutMs` belongs on + the NODE (`FlowNodeSchema.timeoutMs`), not in its config. + - `script`: `functionName` → `function`, `input` → `inputs` (the singular + stays canonical on `connector_action`'s `connectorConfig.input` — do not + "fix" that one). The five `actionType`-branch keys keep their existing + `retiredKey()` tombstones. + - `decision`: `config.condition` (singular) is **not** renamed to + `conditions`. Nothing reads it on a decision — it is the trigger gate on a + `start` node and inert everywhere else (#4414) — and declaring branches here + _and_ on the out-edges is the double-declaration #4414 was filed for. + Branching lives on the out-edges. On a decision BRANCH the predicate slot is + `expression`, so `condition` → `expression` there. + - decision branch `target`: a VIRTUAL designer column projected from the + node's out-edges, never stored — route by matching the branch `label` to an + out-edge `label`. + + For a key rewritten at load by an ADR-0087 D2 conversion, reaching this + rejection means the config carries BOTH spellings: `renameConfigKey` leaves a + shadowed alias in place rather than clobbering the canonical winner, so the + retired twin is dead weight and should be deleted. + +- 7055c22: Close the responsive/SDUI-styling shapes against unknown keys (#4001 batch 13, ADR-0078) + + zod's default is `.strip`: a key a schema does not declare is silently discarded + and the parse still succeeds. On an authoring surface that is the worst failure + mode — the author (increasingly, an AI) gets a success envelope and ships + metadata that quietly ignores what they wrote. + + **BREAKING.** All four shapes in `ui/responsive.zod.ts` now raise a named, + fixable error instead of dropping the key: `ResponsiveConfigSchema`, + `ResponsiveStylesSchema`, and the two per-breakpoint maps behind + `responsive.columns` / `responsive.order`. + + **What this actually fixes is a nested one.** `PageComponentSchema` has been + `.strict()` since ADR-0089 D3a — and that never reached these blocks, because + strictness does not recurse. So this component parsed **clean**: + + ```ts + PageComponentSchema.parse({ + type: "element:text", + id: "t1", + responsiveStyles: { lg: { fontSize: "40px" } }, + responsive: { colums: { lg: 4 }, hideOn: ["xs"] }, + }); + // → { …, responsiveStyles: {}, responsive: {} } + ``` + + Every styling and layout instruction the author wrote, gone, reported valid — the + node renders unstyled and nothing says why. + + **The renames, and where the wrong word comes from.** This file carries TWO + breakpoint vocabularies sixteen lines apart on the same page component: + `responsiveStyles` uses ADR-0065's desktop-first buckets, `responsive` uses the + Tailwind `xs`…`2xl` ramp. Crossing them is not a typo and edit distance cannot + bridge it, so the aliases run both ways: + + | you wrote | write instead | where the other word comes from | + | ---------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------- | + | `responsiveStyles: { xs / sm / md }` | `xsmall` / `small` / `medium` | the sibling `responsive` key's `BreakpointName` ramp | + | `responsiveStyles: { lg / xl / 2xl }` | `large` | same, folded onto the unconditional base | + | `columns: { large / medium / small / xsmall }` | `lg` / `md` / `sm` / `xs` | the sibling `responsiveStyles` buckets | + | `columns: { xxl }` | `2xl` | the near-miss this file's own test has pinned as invalid since before #4001 | + | `responsive: { hidden }` / `{ hideOn }` | `hiddenOn` | objectui's resolved `useResponsiveConfig` result | + + Two are prescriptions rather than renames, because a rename would be wrong. A + bare breakpoint name at the `responsive` level (`responsive: { sm: … }`) is the + legacy breakpoint-keyed shape from the `view.responsive` retired in 17 (#3896) — + three keys are plausible targets, so each name gets its own text naming all + three. And a `responsiveStyles` bucket written on `responsive` (or vice versa) is + a wrong-layer pointer to the sibling key, not a rename. + + `StyleMapSchema` stays **deliberately open** — its key space is every CSS + property, not a contract we own — pinned in the schema JSDoc, in a test, and in + the #4001 ledger. + + **Nothing in `ui/touch|animation|dnd|keyboard|offline.zod.ts` changed**, and that + is deliberate. The ledger scheduled their 22 sites as `authorable (p)`; resolving + the `(p)` found no authoring door at all — nothing declares a carrier key for + them, a BFS from all 24 metadata-type roots plus `defineStack` never reaches + them (with three positive controls passing in the same run), and no `.parse()` on + any of them exists in this repo, objectui, or the example apps. `.strict()` is a + property of a parse; there is no parse. Retiring them or giving them a carrier is + ADR-0049 enforce-or-remove, tracked in #4988 — not a breaking change to spend + here. + +- 785a748: feat(spec)!: 收紧 action param 选项、公开分享、报表排序、数据集语义层与仪表盘小组件的嵌套未知键(#4001 批 14) + + `ui/` 方向第二波。账本重测记的 11 个 strip 站点,逐个做门测量后:**9 个收紧,2 个改判**。 + + ## 破坏性变更 —— 9 个形状不再静默丢弃未知键 + + | 形状 | 文件 | 之前 | + | -------------------------------------------------------------- | --------------------- | ------------------------------- | + | `ActionParamSchema.options[]` | `ui/action.zod.ts` | `{ label, value }` 之外的键被剥 | + | `SharingConfigSchema` | `ui/sharing.zod.ts` | 同上 | + | `ReportSortSchema` · `JoinedReportBlockSchema` | `ui/report.zod.ts` | 同上 | + | `DatasetDimensionSchema` · `DatasetMeasureSchema` · `.derived` | `ui/dataset.zod.ts` | 同上 | + | `DashboardWidgetSchema.compareTo`(对象分支)· `.layout` | `ui/dashboard.zod.ts` | 同上 | + + **升级方式:把被拒的键改成错误信息点名的那个。** 拒绝本身就带处方 —— 它点名面、原样回显写错的键,并在可能时给出规范拼法。没有任何键被移除,也没有任何合法形状变得不合法:这些 schema 接受的键集合完全没变,变的只是「写了别的会怎样」。 + + 其中四个是 **strict 外壳套 strip 子块** —— 容器早就 strict,但**严格性不递归**: + + - `ActionParamSchema` 自 #3746 起 strict,而它的 `options[]` 条目不是。实测一个带 `color` / `visibleWhen` / `icon` / `disabled` 的选项过 `getMetadataTypeSchema('action')`,出来是 `{"label":"Overload","value":"overload"}` —— 四个键在任何 renderer 看到之前就没了,报告成功。 + - `DashboardWidgetSchema` 自 ADR-0021 起 strict,而 `compareTo` 的对象分支和 `layout` 不是。 + - `DatasetSchema` / `ReportSchema` 同理,漏的正是承载语义契约的那几个子形状。 + + ### 为什么 `action` 的选项走 strict,而兄弟 `bulk-action` 走 `.passthrough()` + + 两边不同是**测出来的,不是照搬的**。#4909 给 bulk-action 选项条目 `.passthrough()` 的两条理由在这里都不成立:那边的 def「left as-authored」逐字到达 grid(中间没有 spec 门),且 objectui 的 `BulkActionParam` 声明了显式 `[key: string]: unknown` 兜底;这边有一道**已经在剥**的门,落点是**封闭**的 `SelectOptionMetadata` 接口。目标词汇封闭,正是「声明」胜过「容忍」的场合。action param 选项是否该讲字段级的逐选项词汇,是独立的能力问题(#5016),不在本批猜。 + + ## 两个形状改判为第四类 `no door`,**不**收紧 + + `NotificationActionSchema`(`ui/notification.zod.ts`)与 `EmbedConfigSchema`(`ui/sharing.zod.ts`)**没有授权门**:没有承载键、从 24 个 metadata-type root + `defineStack` 做 BFS(6860 节点)不可达、三个仓里除自测外零 `.parse()`。收紧它们会花掉一次破坏性变更去留下「一个被精确校验的死槽位 —— 更有说服力的谎言」。ADR-0049 定去留:#5015。 + + `ui/sharing.zod.ts` 是账本第一个**一行两判**的文件:同一文件里 `SharingConfig` 是活门(`FormViewSchema.sharing` 承载,`rest-server.ts` 靠 `sharing.allowAnonymous` + `sharing.publicLink` 挂匿名表单路由,两个示例应用都在写),`EmbedConfig` 没有门。按文件下判断,无论落哪边都会错一半。 + + ## 策展依据是同仓的兄弟契约,不是编辑距离 + + - **dataset** 锚在本模块自己 header 点名的 `data/analytics.zod.ts` Cube 层:Cube metric 的 `type` **就是**聚合函数,所以 `{ name: 'revenue', type: 'sum', field: 'amount' }` 过去 parse 干净、算出来是 `count`。`sql` 只给 guidance 不给别名 —— 把 `SUM(amount)` 指向吃字段路径的 `field`,是本战役自己要消灭的那种错误处方。 + - **report** 的排序键是作者会遇到的**第三种** sort 拼法(`SortNodeSchema` 的 `{field, order}`、小组件的扁平 `sortBy`/`sortOrder`、这里的 `{by, direction}`),而且映射方向相反,任何一种都推不出来。 + - **dashboard `layout`** 锚在 React-Grid-Layout:`minW`/`static`/`i` 等给逐键 guidance,而不是改名到无关的位置键上。 + - **sharing** 的别名全是 camelCase 目标,兜底系统性够不着(#4990)。`allowAnonymous` 写错拼法的后果是**表单保持私有而作者以为公开**。 + + ## 已知触及面限制(实测记录,非推断) + + `compareTo` 是 union。zod 把失配 union 折叠成一条顶层 `invalid_union`(message 是裸的 `Invalid input`),分支错误挂在 `issue.errors`,而 `zodIssuesToFields` 只映射顶层 —— **处方产生了但送不到作者手上**。拒绝不受影响,这仍是 #4001 的收益。传输缺陷是 #5014,影响本战役放进 union 分支的每一条策展文案。测试对这两半**分开** pin,免得一个绿测试冒充一条没人打印的消息。 + +- 3af0354: **未知键收紧:`ui/theme.zod.ts` 全部 14 个站点 + `ui/chart.zod.ts` 5 / 7 个站点(#4001 批 15)** + + 作者写进主题和图表配置里的未声明键,过去被 zod 默认的 `.strip` 静默丢弃、解析照常成功。现在是一条点名该键、并在能识别时给出正确拼法的报错。 + + **先测门,再收紧。** 两个文件在账本上都标着 `authorable (p)`,`(p)` 是待验证: + + - `theme.zod.ts` —— 门是真的,而且有两道:`stack.zod.ts` 声明 `themes: z.array(ThemeSchema)`(所以 `defineStack()` 在每次启动和 `objectstack build` 时解析每个主题),`defineTheme()` 直接解析一个。从 24 个 metadata-type root 加 `ObjectStackSchema` 做 BFS,文件内每个 schema 都可达。**14/14 收紧。** + - `chart.zod.ts` —— 5 个可达(`DashboardWidget.chartConfig` / `ReportChartSchema`),已收紧;**`ChartAggregateSchema` 与 `ChartGroupBySchema` 的 object 分支不可达,故意保持原样**,见下。 + + ## 破坏性变更 · 迁移 + + 以下写法过去被静默接受(键被丢弃),现在报错。括号内是新报错直接给出的处方。 + + **主题(`defineStack({ themes })` / `defineTheme()`)** + + | 旧写法 | 改成 | + | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | + | `colors: { card, foreground, mutedForeground, muted, destructive }` | `surface` / `text` / `textSecondary` / `disabled` / `error` | + | `typography.fontSize: { md }` | `base`(`borderRadius`/`shadows` 有 `md`,字号阶梯没有) | + | `typography.fontWeight: { base }` | `normal` | + | `animation.timing: { easeIn, easeOut, easeInOut }` | `ease_in` / `ease_out` / `ease_in_out` | + | `shadows: { inset }` | `inner` | + | `zIndex: { backdrop, overlay }` | `modalBackdrop` | + | 顶层 `palette` / `radius` / `shadow` / `animations` / `cssVars` / `extend` | `colors` / `borderRadius` / `shadows` / `animation` / `customVars` / `extends` | + | #3494 删除的 8 个 prop(`spacing` / `breakpoints` / `logo` / `density` / `wcagContrast` / `rtl` / `touchTarget` / `keyboardNavigation`) | 各自带独立墓碑处方;多数指向 `customVars`,`logo` 指向 app 的 `branding.logo` | + + **图表(dashboard widget 的 `chartConfig` / report 的 `chart`)** + + | 旧写法 | 改成 | + | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | + | `chartType` | `type`(`chartType` 是内部拼写,从来不是作者契约) | + | `legend` / `dataLabels` | `showLegend` / `showDataLabels` | + | `interactions` / `annotation` | `interaction` / `annotations`(同一个块里一个单数一个复数) | + | axis 上的 `name` / `label` / `dataKey` | `field` / `title` | + | series 上的 `field` / `title` / `stackId` / `strokeDasharray` | `name` / `label` / `stack` / `dashArray` | + | annotation 上的 `from` / `to` | `value` / `endValue` | + | `interaction.zoom` / `interaction.clickAction`(#3752 已删) | `brush: true` / `onSegmentClick`、`ReportSchema.drilldown`、widget 的 `options` 袋 | + | `width` / `stacked` / `dataset` / `objectName` / `aggregate` / `options` | 都是层级放错,报错点名正确的那一层(`layout.w`、`series[].stack`、widget 自己的键、react prop) | + + ⚠️ **严格性会顺着 `.extend()` 传到 `ReportChartSchema`**(`ChartConfigSchema.extend(...)`)。这是有意的,并有测试钉住:report chart 只是把 `xAxis`/`yAxis` 收窄成 dataset 维度/度量名,不新增键,所以继承的键集正好。 + + ## 两个站点故意没收紧 + + `ChartAggregateSchema` 和 `ChartGroupBySchema` 的 object 分支**有活的承载键**(react 层 `< ObjectChart objectName aggregate={…} >`,objectui 的 `ObjectChart` 真的读它跑查询),**但没有任何 parse**:两者从所有 metadata-type root 都不可达,三个仓库里除单测外无人 `.parse()`,而唯一审查它的 react 页发布 lint 是手写重推规则、从不检查未知键。 + + `.strict()` 是 parse 的属性,这里没有 parse —— 收紧只会让文件看起来完成,并留下*一个被精确校验的死槽位*(#4583)。账本因此新增第四类 **`no gate`**(承载键活、parse 缺),与批 13 的 `no door`(承载键本身不存在)并列:两者处方相反,前者该接闸门,后者该走 ADR-0049 退役。已归档为独立 issue。 + + 主题里那些**发出后无人读取**的 CSS 变量(`--font-size-*` / `--z-*` / `--duration-*` …)是 ADR-0049 的 liveness 题目,不是未知键题目,同样单独归档 —— 收紧能让被丢弃的键变响,不能让一个槽位变活。 + +- 866ff16: Close `AriaProps` against unknown keys, and reclassify `widget` + five `i18n` shapes as no-door (#4001 batch 16, ADR-0078) + + zod's default is `.strip`: a key a schema does not declare is silently discarded + and the parse still succeeds. On an authoring surface that is the worst failure + mode — the author (increasingly, an AI) gets a success envelope and ships + metadata that quietly ignores what they wrote. + + **BREAKING — one shape.** `AriaPropsSchema` (`ui/i18n.zod.ts`) now raises a + named, fixable error instead of dropping the key. It is carried as `aria:` on + roughly thirty live shapes under six metadata-type roots — `ListViewSchema`, + `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, + `ActionSchema`, and twenty SDUI component defs — so this is the highest-fan-out + single site the `ui/` wave has closed. + + **What it was doing.** Through the `view` metadata root, this parsed **clean**: + + ```ts + getMetadataTypeSchema("view").parse({ + listViews: { + my_view: { + type: "grid", + columns: ["name"], + aria: { label: "Accounts", describedBy: "accounts-help" }, + }, + }, + }); + // → aria: {} + ``` + + Both keys gone, reported valid. The accessible name existed in the source file + and nowhere else — a screen-reader user hears the DOM default, and nothing in the + toolchain ever said so. Those two spellings are not hypothetical: they are what + objectui's `ARIA_KEY_ALIASES` normalizer folds at the `ListView` boundary + (objectui#2890), i.e. what stored view metadata actually carries. + + **The renames, each anchored to a named sibling contract.** + + | you wrote | write instead | where the wrong word comes from | + | ------------- | ----------------- | -------------------------------------------------------------------------------------------------- | + | `label` | `ariaLabel` | objectui's stored legacy spelling, folded by `normalizeListViewSchema` | + | `describedBy` | `ariaDescribedBy` | same | + | `ariaRole` | `role` | this shape's own inconsistency — two of its three keys carry the `aria` prefix and `role` does not | + + `arialabel`, `ariaLabell`, `ariadescribedby`, `aria-label` and `roles` are left to + the edit-distance fallback, measured before anything was hand-written: an alias + for a key the fallback already reaches is transcription, not judgement. + + **Two keys get a prescription instead of a rename**, because renaming them would + be wrong (the ledger's finding 7 — this campaign's own fix once signposting the + way into the failure it exists to kill): + + - `live` is real and rendered — by objectui's `ListView` alone, which reads + `schema.aria?.live` and emits `aria-live`. objectui declares it as + `AriaPropsSchema.extend({ live })`, so **that surface keeps accepting it** (and + now inherits this error map for everything else). On any other surface the + message says where `live` IS valid rather than pointing at a declared key that + means something else. Promoting it into the shared shape would advertise + `aria-live` on twenty-nine renderers that do not implement it; the promotion + question is **#5058**. + - `ariaLabelledBy` / `labelledBy` — `aria-labelledby` references another + element's id, which is not the same thing as `ariaLabel` (a literal string), so + there is nothing to rename it to. The gap is named, and is also #5058. + + **A `.strip()` was added to four files this batch did not otherwise touch.** + `animation.zod.ts`, `dnd.zod.ts` (×2), `keyboard.zod.ts` and `touch.zod.ts` build + their config shapes as `z.object({…}).merge(AriaPropsSchema.partial())`, and + `.merge()` adopts the incoming schema's unknown-key posture — so closing + `AriaProps` would have silently closed all five of those shapes too, with zod's + generic message and against #4988's measured verdict that nothing parses them. + The explicit `.strip()` holds their posture; `i18n.test.ts` pins it. + + **Nothing in `ui/widget.zod.ts` changed, and five of `ui/i18n.zod.ts`'s six + shapes were left open** — deliberately, on measurement. The ledger scheduled + `widget` as `authorable (p)` / 9 sites and warned that `i18n`'s label shapes were + "wide-open records by design"; resolving both found something more specific. + `widget.zod.ts` has no authoring door at all: nothing under `packages/spec/src` + imports it except the barrel, a BFS from all 24 metadata-type roots plus + `defineStack` never reaches it, and no `.parse()` on any of its shapes exists in + `objectstack`, `objectui` or `cloud` outside its own tests. The same holds for + `I18nObjectSchema`, `PluralRuleSchema`, `NumberFormatSchema`, `DateFormatSchema` + and `LocaleConfigSchema`. `.strict()` is a property of a parse; there is no parse. + Retiring them or giving them a carrier is ADR-0049 enforce-or-remove, tracked in + **#5055** — not a breaking change to spend here. + + The warning about the open record was aimed one level off, and both levels are + now recorded: `I18nObject.params` is a `z.record` interpolation bag whose key + space is whatever the message template names — openness there is the contract, and + it was never a site this ratchet could close. The config block the map assumed was + open alongside it (`AriaProps`) turned out to be the directory's most widely + carried live shape. + + Zero-breakage evidence: full `@objectstack/spec` suite, `tsc --noEmit`, all ten + spec `check:*` gates, `objectstack validate` on app-showcase / app-crm / app-todo, + and an ADR-0087 direct-parse probe over the three apps' **built** artifacts — + zero `aria` slots present, with the probe's negative control proven red on a + legacy-spelled block. + +- 5a85e67: **`ui/component.zod.ts` 的 29 个站点判定为 `no gate`,不收紧(#4001 批 17,ADR-0078)** + + ⚠️ **本条没有破坏性变更,没有迁移,没有任何键的行为改变。** 它记录的是一次测量结果:SDUI 组件 props 这块 `ui/` 目录最大的待收紧面,**根本没有 parse**,所以 `.strict()` 在这里不会强制任何东西。 + + ## 为什么不收紧 + + `.strict()` 是 **parse 的属性**。三条独立测量(2026-08-04,控制组同轮为真): + + 1. **承载键活着,但它是个开放口袋。** `PageComponentSchema.properties` 是 `z.record(z.string(), z.unknown())`。`PageComponentSchema` 自 ADR-0089 D3a 起确实是 `.strict()`,但**严格性不递归**——它守住 component 节点自己的键,`properties` 里面完全不校验,也没有任何地方按 `type` 分派 `ComponentPropsMap`。 + 2. **BFS 不可达。** 从 24 个 metadata-type root 加 `ObjectStackSchema` 出发(复用 `build-schemas.ts` 自己的 `zodChildSchemas`/`zodShapeOf`,即 #4650 闭包,6899 个节点),本文件 **52 个目标全部 UNREACHABLE**(21 个导出 schema + `ComponentPropsMap` 全部 31 个条目);同一轮里 `PageSchema` / `PageComponentSchema` / `PageRegionSchema` / `ThemeSchema` / `ChartConfigSchema` / `ResponsiveConfigSchema` 六个正控制组全部 `root-graph`,批 13 的 no-door 形状保持 unreachable。BFS 正好停在 `properties`。 + 3. **三个仓库无生产 parse 点。** `objectstack` / `objectui` / `cloud` 中,对本文件任何 schema 的 `.parse()`/`.safeParse()` 全部落在本文件自己的单测里。objectui 手写平行的 React interface、只引用推断类型;cloud 引用为 0;`react-blocks.ts` 只用 `Object.keys(ComponentPropsMap)` 取类型名。 + + 经验证据(`definePage()` 就是 `PageSchema.parse()`,活的授权门):example 语料 10/10 个页面上,写进 `components[].properties` 的未声明键**原样通过并被保留**;同一个键放到外面一层(`properties` 的兄弟位)10/10 被拒——这个负控制组才让前一个数字有意义。 + + ## `no gate`,不是 `no door` —— 不要退役 + + 这些词汇是**活的**,不能按 ADR-0049 退役:objectui 的 `SchemaRenderer` 把 `properties` 整个 hoist 到节点上,再把不在固定 deny-list 上的**每一个**作者键 spread 成 React prop。所以拼错的键既不被拒、也不被丢,而是安静地流到渲染器再被忽略——正是 ADR-0078 要消灭的形状,只是位置比本 ratchet 能触及的层更低一层。 + + 这确实是 #4909 的 open-slot 形状,但在一个没人 parse 的 schema 上,`.passthrough()` 和 `.strict()` 一样空洞,所以**没有改任何 posture**。 + + contract-first 的修法是把 parse 接到承载键自己的闸门上,已单独立为 **#5068**;那个 issue 同时记录了两条使它不能顺手做的约束:`type` 是开放 union(`record:line_items` 这类未注册类型在现实中被使用),以及真实页面已经写了这些 schema 未声明的形状(`record:details` 的 `sections[].fields[]`/`hideFields[]`、record picker 的 `labelField`)。#5068 落地后本文件才重新变成 `authorable`,收紧才有意义。 + + 判定写在三处(文件头、`component.test.ts` 的钉子——含一条 `properties` 一旦获得类型化分派就变红的断言、账本 `ui/` 两张表),改要一起改。 + + 账本连带效果(与 #5042 批 14、#5069 批 16、#5070 批 18 合并后,从存活行重算):`ui/` 的 authorable strip 从 35 降到 **6 of 75**——重分类只是换类不是出列,总数不变,文件保留 29/29 行,`check:strictness-ledger` 的反向钉子仍然管着它;`no gate` 从 2 涨到 **31**。批 18 关掉 15 个真门、批 17 测出 29 个没有门,两件事叠加后 `ui/` 只剩 **6 个** authorable strip 站点。由此得到这个目录现在最大的一个事实:**`ui/` 剩余 75 个 strip 站点里有 69 个(92%)根本不是本 ratchet 的工作**(38 个 `no door` + 31 个 `no gate`)。排后续 `ui/` 收紧批次前请先读这个数——这个目录的 ratchet 已经接近完成,剩下的绝大多数是别的 issue 的工作。 + +- c183a12: spec(ui): `userFilters.allowAddTab` 提升进契约,`UserFiltersSchema` 随之收紧 (#5073) + + `UserFiltersSchema` 是 #4001 批 18 在 `ui/view.zod.ts` 留下的最后一块「不是 strictness 问题」的开放形状 —— 挡住它的是一条**能力声明**,不是姿态判断。 + + **为什么之前不能直接关。** objectui 的列表渲染器真读 `config.allowAddTab` 并据此渲染「新增 tab」控件(`plugin-list/src/UserFilters.tsx:182` / `:742`),它自己的 `UserFiltersSchema` 也声明了这个键 —— 两边形状的差集恰好只有这一个。而 `saveMetaItem` 用 `safeParse` 校验后**原样存原始 body**(丢弃 `parsed.data`,好让 Studio 的辅助键活过往返),所以被 strip 掉的只是那份被丢弃的解析结果:存储里键还在,渲染器读得到,**这个能力今天是工作的**。直接收紧不是「把静默失效变响亮」,而是把一个已发布、在用的配置变成 422 —— 而且 422 会点名一个作者本来写对了的键,正是本战役 finding 7 的形状(平台权威把作者引向删掉能工作的东西)。 + + **裁定与落地(维护者 2026-08-04,选项 A):promote 后收紧,同 PR 完成。** `allowAddTab` 现在**声明**在 `UserFiltersSchema` 上,能力因此可从契约被发现 —— JSON Schema、Studio 的 SchemaForm、AI 作者都看得到,而不是只存在于一个 React 文件里。被否决的是判它为 objectui-only 扩展(`SANCTIONED_LOCAL`):那会让 `packages/spec` 与 objectui 成为同一份契约的两个事实来源,正是 #2231 的 derive-by-reference 统一要消掉的分叉(PD#12)。声明的措辞刻意收窄到渲染器真做的事 —— 它声明「渲染出新增 tab 的入口」,不承诺点击后能创建预设(objectui 那个按钮目前没有 click handler,已另行立案),因为承诺更多就是 PD#10 的「宣传运行时并不交付的能力」。 + + ## BREAKING + + **1. `userFilters` 上的未知键从静默丢弃变为拒绝。** + + ```diff + userFilters: { + element: 'tabs', + - allowAddTabs: true, // 拼错 → 以前静默消失,现在 422(并提示 → allowAddTab) + + allowAddTab: true, + } + ``` + + FROM → TO:未声明的键 → 删除它,或改成它想表达的那个已声明键。错误信息会点名该键并给出最近的候选。`allowAddTab` 本身**不需要迁移** —— 它现在是合法声明键,原有配置照常通过。 + + **2. 对象列表视图(`ObjectUserFiltersSchema`)拒绝 page-only 的三个键:`tabs` / `showAllRecords` / `allowAddTab`。** + + 这三个键在对象视图上一直是无效的(`ObjectUserFiltersSchema` 由 `UserFiltersSchema.omit()` 派生,而 `.omit()` 继承基类姿态),此前被静默丢弃 —— 与此同时 CLI lint(`packages/lint/src/validate-list-view-mode.ts`)早就在报同一个配置。两扇门从此一致。 + + ```diff + // 对象视图:tab 栏的角色已被 ViewTabBar(已存视图切换器)占用 + listViews: { + - // userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: '我的' }] } + + mine: { label: '我的', filter: [['owner', '=', '{userId}']] }, // 每个具名视图渲染成一个分段 tab + } + ``` + + FROM → TO:`userFilters.tabs` → 对象的 `listViews` 具名条目;`showAllRecords` → 默认列表视图本身就是「全部记录」入口;`allowAddTab` → 由 ViewTabBar 自带的新增控件承担。三条拒绝各自带 `guidance` 处方,不是裸的 "unrecognized key"。 + + 派生变体同时改为携带**自己的**错误映射:`.omit()` 会连基类的 `knownKeys` 一起继承,而那份候选列表是从基类形状读的、仍然含被 omit 掉的键 —— 实测在对象视图上写 `tab` 会被答复 _"Did you mean `tab` → `tabs`?"_,把作者指向这个形状唯一拒绝的键。形状仍由 `.omit()` 派生(#2231 不变),候选池改为按 omit 后的形状构建。 + +- 06df4fa: **View sub-blocks now reject unknown keys instead of dropping them (#4001 批 18).** + + Fifteen object shapes in `ui/view.zod.ts` were still zod's default `.strip`: a key + the schema did not declare was discarded and the parse still succeeded, so the view + rendered without whatever the key was meant to configure — no error, no warning, + `tsc` green. They are now closed, and the rejection names the surface, echoes the + offending key, and suggests the right one. + + Closed shapes: `ViewDataSchema`'s four provider arms (`object` / `api` / `value` / + `schema`), `UserFilterField.options`, `GanttQuickFilter.options`, + `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, the `keyField` block on a form field, `FormView.subforms`, and all + four arms of `FormView.submitBehavior`. + + **Migration — the spellings that used to be silently dropped and now raise:** + + | You wrote | Where | Write instead | + | ---------------------- | -------------------------------------- | ------------------------------------------------------------------------ | + | `object` | `form.subforms[]` | `childObject` | + | `objectName` | `data: { provider: 'object' }` | `object` | + | `delay` / `delayMS` | `submitBehavior: { kind: 'redirect' }` | `delayMs` | + | `visibleWhen` / `when` | `list.conditionalFormatting[]` | `condition` | + | `description` / `text` | `list.emptyState` | `message` | + | `count` | a user-filter option | nothing — counts are computed; set `showCount: true` on the filter field | + | `action` / `button` | `list.emptyState` | configure the `addRecord` block instead | + + `submitBehavior` is now a discriminated union on the `kind` literal it already + required. No accepted input changes shape; the rejection improves — a plain union + reported `invalid_union` with one sub-error per arm, and the useful message did not + survive to the CLI (#5014). + + **Not changed, deliberately:** `GanttConfig` / `TreeConfig` stay open at the parent + (`.passthrough()`) so renderer-ahead knobs keep reaching plugin-gantt / plugin-tree — + only the nested `tooltipFields` entry closed. `ListView.sort` stays open too — the console + stamps a UI row `id` into it (`.strip()` on a wire member does NOT recurse, so a + closed nested block 422s a console PUT regardless). `UserFiltersSchema`, + `ViewItemSchema` and the private `FormFieldBase` also stay open, each for a measured reason recorded + in the schema's own JSDoc, in `view-strictness-batch18.test.ts`, and in the `ui/` row + of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + +### Minor Changes + +- 2e284b2: fix(spec): stop silently stripping widget config off a bulk-action param option (#4001) + + `BulkActionParamSchema`'s `options[]` entry is now `.passthrough()`, matching its + parent. **Behaviour change, loosening only**: keys other than `label` / `value` on + an option used to be _removed at parse_ and are now preserved. Nothing that parsed + before stops parsing, and no key changes meaning — an authored option simply keeps + what it was written with. + + Concretely, this used to happen without a warning: + + ```ts + // authored + options: [ + { label: "In Review", value: "in_review", color: "#8B5CF6", icon: "eye" }, + ]; + // parsed, BEFORE + options: [{ label: "In Review", value: "in_review" }]; + ``` + + `color` and `icon` are not decoration the renderer ignores. objectui's + `bulkParamToField` spreads every option entry into the field metadata + (`packages/plugin-grid/src/components/bulkParamToField.ts:131`), where the widget + vocabulary is `SelectOptionMetadata` (`packages/types/src/field-types.ts:288`) — + `color`, `icon`, `disabled` and `visibleWhen` beyond the declared pair, and read + (`option?.color`, `packages/fields/src/index.tsx:1089`). So the strip deleted + config that would otherwise have rendered, on the authoring side, invisibly. + + Nothing to migrate. If you dropped option colors/icons because "the spec ate them", + they work now. The strictness ledger's prose already described this level as + deliberately open while only the parent schema said so in code; the code now says + it too, which is the part a machine can check. + +- 0161c7f: feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022) + + `drillDown` has driven a real capability since long before this release: click a + bar or a slice on an `` and objectui opens the underlying records, + filtered by the clicked category, in a drawer. The protocol declared it + **nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside + it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was + simply ignored at click time. This is Prime Directive #10 inverted: not declared + without being delivered, but delivered without ever being declared. + + It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing + that parsed before stops parsing. + + ## What you can write + + `drillDown` is a prop on the react-tier `` block: + + ```jsx + + ``` + + | key | type | meaning | + | --------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | + | `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it | + | `filter` | `Record` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category | + | `title` | `string` | Drawer/dialog heading; supports `${event.*}` | + | `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer | + | `columns` | `string[]` | Column whitelist for the drilled list | + | `maxRows` | `number` | Rows per page in the drilled list | + + Every one of those six is a key objectui's `ObjectChart` was measured to read. + The renderer's own drill type is wider — it is shared with the table / pivot / + metric widgets — and the extra keys are **deliberately not declared**, because a + chart reads none of them: + + - **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment + is always an aggregate, so there is nothing to discriminate. + - **`report`** (drill into a report instead of a record list) is a metric/pivot + capability. + - **`view`** and **`sort`** are read by _no_ renderer at all (objectui#3354). + - **`target: 'navigate'`** is implemented for the other widgets but not for a + chart, which falls back to the drawer. + + Writing any of them is now a loud rejection that says which surface owns it, + rather than a value that silently does nothing. + + ## Where it is NOT declared, and why that is deliberate + + **Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard + widget has no per-widget drill configuration, by design: an ADR-0021 + dataset-bound widget drills through the semantic layer, deriving the target + object and filter from the dataset row that was clicked. That is what + `content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer + does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget` + forwards exactly one key out of it (`showLegend`). Declaring the drill there + would have produced authorable metadata that parses clean and never reaches a + renderer — the failure this campaign removes elsewhere. + + So the three places an author might reach for it now answer instead of shrugging: + + - `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop. + - `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard + drill-through is **automatic**, and naming both configurable drills. + - `report.drillDown` → rejected, pointing back at the chart prop. + + ## `drillDown` is not `drilldown` + + Two capabilities, one letter apart, and they are now disambiguated in both + directions at the schema gate: + + | | `drillDown` | `drilldown` | + | -------- | -------------------------- | ----------------------------------------------- | + | spelling | camelCase | all lowercase | + | type | configuration object | boolean | + | surface | react `` prop | `ReportSchema` key (ADR-0021 D2, on by default) | + + Edit distance alone gets this wrong — the two spellings are a distance of 1, so a + plain "did you mean" would happily send an author writing `drillDown` on a report + to `drilldown`, where their config object then fails a second time as a boolean. + Both gates name the **type** difference, not just the spelling. + + ## Enforced, not just declared + + `@objectstack/lint`'s react-page publish gate now **parses** the schema + (`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal, + rather than re-deriving the rules. Unknown keys, the wrong `target`, and the + near-key spelling all fail the build with the schema's own prescription. A value + assembled from React state is skipped, unchanged: an unresolvable binding is not + a wrong one (ADR-0072 D1). + +- c52e608: fix(metadata,spec): the endpoint publish gates now guard the metadata write path too (#5189, #5040 E7b) + + #5111 (E7) hung the five per-endpoint `apis:` gates on + `ObjectStackDefinitionSchema`, which every path that parses a **stack** runs + through — `defineStack`, `os validate`, the lint scorer, artifact ingest, + `EnvironmentArtifactSchema.metadata`. #5189 proved a stored `api` item need + never have been part of a stack: `MetadataManager.publishPackage`, a direct + `metadata.register()` and a Studio metadata write each mint one item at a time + and saw no gate at all. + + Three of the five gates degrade safely when bypassed — the executor answers a + structured 501 naming the item, and a path outside the `apps//` + carve-out simply matches nothing. **ADR-0121 D6 has no runtime counterpart**: + the runtime honours `authRequired: false` faithfully and `deriveBucketConfig` + returns `null` for a budget whose `enabled` is not `true`, so the bypass minted + an anonymous, zero-quota execution entry point — the exact shape D6 exists to + forbid. + + Two doors now, both running the SAME gate function rather than a second copy of + the criteria: + + - **Publish** — `MetadataManager.publishPackage` runs + `validateApiEndpointDeclarations` over the package's `api` items and fails + the publish, naming each endpoint and the key to fix, on the same + `validationErrors` surface it already uses. This pass is **not** governed by + `options.validate`: an opt-out on a security gate is the bypass this fixed. + - **Load** — the endpoint matcher's index build re-applies the _identity-free_ + subset (supported subset, mapping, policy/D6) to every stored item. A + declaration that never passed publish is EXCLUDED from the index and named at + `error` level, so a bypassed endpoint answers 404 with a loud log instead of + answering anonymously and unmetered. The namespace and uniqueness gates are + deliberately not applied there — both need a stack identity a stored row does + not carry. + + **New in `@objectstack/spec/api`** (the module was package-internal in #5111, + whose only consumer was one file away): + `validateApiEndpointDeclarations`, `identityFreeEndpointGateFailure`, + `EndpointGateIssue`, `EndpointGateIdentity`. + + **New option — `publishPackage(id, { namespace })`.** `MetadataManager` indexes + items by `packageId` and carries no manifest, so it cannot prove a namespace on + its own and will **not** infer one from the items it is judging (an + author-supplied value would make the ADR-0121 D1/D2 carve-out gate vacuous). + Callers that hold the package manifest pass its explicit `manifest.namespace`; + without it the namespace gate fails and the package's `api` items do not + publish — which is the rule, not a limitation: a publish that cannot prove a + namespace must not mint a URL under one. Packages that declare no `api` items + are untouched. + +- 77be690: feat(spec): 执行器契约面 —— `IMetadataService.matchEndpoint?` 与 `IHttpServer.setFallbackHandler?` 可选成员(#5040 执行器 E1) + + **纯声明,零行为变更。** 本改动只在 `packages/spec/src/contracts/` 增加两个**可选**契约成员与一个导出类型;仓内没有任何实现体、没有任何接线,现网行为逐字节不变。声明式 `ApiEndpoint` 在 v17 仍被 publish 硬拒(#4936 裁决),本单落的是它未来得以执行所需的契约前件(contract-first 首件)。 + + **1. `IMetadataService.matchEndpoint?(query: { path, method })`** — 把一次请求的 `method`+`path` 解析为拥有该路由的 `api` 元数据条目,或在无人声明时返回 `undefined`。这是 HTTP dispatcher 在「内建 domain 均未认领」与「答语义 404」之间的那一步。随之导出新类型 `ApiEndpointMatch`: + + - `endpoint` 是 `ApiEndpointSchema.parse` **之后**的形状 —— 默认值已物化,而非存储里的原始 JSON。作者漏写 `authRequired` 时消费端拿到的是 `true`(schema 默认值),因此消费端永远读不到「缺省」这个中间态,也就不可能把一个缺失的安全默认误读成放行。 + - `params` 在 17.x **恒为 `{}`**。`ApiEndpointSchema.path` 词表已冻结(ADR-0121),既未定义 `:param` 也未定义 `{param}`,本契约**刻意不发明**模板语法 —— 只存在于实现里的语法就是隐藏方言(Prime Directive #12)。槽位现在就声明出来,是为了将来真要加路径模板时,那是词表的加法,而不是本契约的破坏性变更。 + + **2. `IHttpServer.setFallbackHandler?(handler: RouteHandler)`** — 传输层兜底 seam:仅当**全部显式注册的路由均未命中**后才被调用。它在结构上不可能遮蔽任何已注册路由,因此零注册顺序依赖 —— 这正是它优于「通配路由」方案的原因,后者由插件 `start()` 顺序下的 first-registration-wins 决定归属,即 ADR-0076 D11「一条路由一个属主」要防的病灶。第二条保证同样载入契约:兜底 handler 收到的 `req.body` **可读**,与 `use()` 中间件契约明确「body 不填充」相反(在 `use()` 处解析 body 会在真正拥有它的路由 handler 之前吃掉请求流)—— 这条差异正是中间件 seam 无法承载动态端点、而必须新增本成员的原因:由 flow 或 `create` 操作支撑的声明式端点必须读 body。 + + **两者均为可选成员**,消费端按仓内既有惯例以 `typeof x === 'function'` 探测(同 `watch?` / `subscribe?` / `getRawApp?`)。不实现它的 `metadata` 槽位占用者、无法表达 not-found 钩子的适配器,都仍然满足契约,消费端退化到既有的未命中应答。因此对现有实现方**无迁移动作**。 + + 生成物影响:`api-surface.json` 新增一行 `ApiEndpointMatch (interface)`(0 breaking / 1 added)。两个新成员是 interface 成员而非导出,不动其余七件生成物。 + +- 55dbbba: feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937) + + Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec` + declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any + of them, so an author wrote a budget, it parsed, and nothing happened (#4686). + `@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense, + that the dispatcher called it and short-circuited with 429 — it had **zero call sites** + outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to + tune did not exist (#4937). Neither half was broken; they were simply never connected, and + both were documented as if they were. + + They are connected now, along one narrow path. + + ## What you write + + ```ts + export default defineStack({ + manifest: { + /* … */ + }, + server: { + security: { + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 }, + }, + trustProxy: false, + }, + }); + ``` + + `server:` is a **new** top-level stack key. Nothing declared it before, so no existing + stack changes behaviour on upgrade — there is no configuration that was inert yesterday + and starts throttling today. + + It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and + nothing else, because those are the two keys with a consumer. It is NOT the nine-key + `HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and + mounting them here would have made seven dead keys writable in one move (their + enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled + budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0` + is refused at `defineStack` rather than at 3am. + + **No `server.port`.** The listening socket belongs to the deployment, not the artifact, and + `objectstack serve -p` already owns it. The precedence rule is recorded in the schema and + the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` > + built-in default.** + + ## What happens + + Every inbound request the server routes — REST, dispatcher, service routes, anything + mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`, + refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429** + with a `Retry-After` computed from the bucket itself and the standard error envelope + (`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered. + + The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for + anonymous traffic — so one abusive session cannot spend another user's budget, and + credential-stuffing traffic (which has no principal yet) is still metered per source. That + IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**; + otherwise it is the transport's own peer address. Undeclared, those headers are attacker + input: honouring them by default would hand anyone an unlimited supply of fresh buckets and + let them drain a chosen victim's. + + Counters live in the kernel `cache` service when one is registered, so a multi-node + deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at + consume time so a cache plugin that registers later is still picked up (#4772). With no + cache service at all it falls back to a per-process store and says so once, naming the + consequence: the effective limit becomes the declared budget multiplied by the number of + nodes, and nothing about the deployment looks wrong. + + ## Also in this change + + - **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation + passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered + middleware could not read the request, write a response, or decline to continue — a + declared seam with no execution behind it, unnoticed because nothing called it. It now + delivers method/path/query/headers plus the transport peer address + (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be + registered before the routes it guards; the kernel's two-phase boot makes that automatic + (`init()` before every `start()`). + - **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it + does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous + in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is + now labelled as the reference material it always was rather than as live defaults. + + ## Explicitly NOT wired + + `ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain + **known-unwired**. Declaring them still changes nothing. They are not retired here either: + the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one + key of a surface that may yet be implemented would only have to be undone. Tracked, not + silent. + +- c5a5996: fix(seed-loader): a roll-up summary left stale by a seed is now loud and counted + + The loader recovers a post-write roll-up summary recompute that exhausts its + retries (`ERR_SUMMARY_RECOMPUTE`), and that recovery is correct: the rows WERE + written, so re-writing them would duplicate them (framework#3147). What was + wrong was the rank of the consequence. A roll-up summary is a **persisted + derived column** on the parent record, so after this the database is internally + inconsistent — the detail rows say one thing and the column that summarizes them + says another — and nothing recomputes it until some later write happens to touch + the same parent, which after a seed may never happen. + + The entire event used to be one `warn` line reading _"records were written + (summary values may be stale)"_. It named no object, counted nothing, and left + `success: true` with every row counter clean, so no operator could see which + aggregate was wrong and no caller could detect it at all + ([#4998](https://github.com/objectstack-ai/objectstack/issues/4998)). + + **It now logs at `error`**, naming the seeded object and the exact stale column + (`account.total_billed`), stating the consequence (the summary and its detail + rows disagree, nothing self-heals, and the seed still reports success) and the + remedy (fix the recompute error and re-run the seed, or trigger any write on the + affected parent to force a recompute), with the original cause attached. This is + the AGENTS.md "Degradation log levels" rule (#4632): persisted state and runtime + state disagreeing while everything looks normal is `error`, not `warn`. + + **And it is counted** — `SeedLoadResult.summariesStale` and + `SeedLoaderResult.summary.totalSummariesStale`, mirroring `referencesDropped` / + `totalReferencesDropped`, which exists for the same shape one layer down ("the + row was written, something derived from it was lost"). A log line is not + something a caller can branch on; these counters are. + + `success` deliberately stays `true`. It answers _"did the rows land"_, and they + did — every consumer treats `success: false` as "the write failed", so flipping + it would hand the protocol seed-apply surface a `false` with an **empty** errors + array and fail package/marketplace installs that in fact wrote every row. The + counter carries the signal instead; a caller that wants to treat a stale + aggregate as fatal reads `summary.totalSummariesStale > 0`. + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected — a payload written before this + release still parses, with `0`. + +- 1e6ab15: feat(spec): `unique` scope vocabulary gains `'organization'` — scope is said, not positional (#4986, ADR-0120 D1/D6) + + `UniqueScopeSchema` (field-level `unique` and `IndexSchema.unique`) widens from + `boolean | 'global'` to `boolean | 'global' | 'organization'`. Purely additive + in 17.x — no existing spelling changes meaning: + + - **Field-level** `'organization'` is the explicit synonym of `true` + (per-organization uniqueness, identical materialization through the driver + predicates: `isUniqueDeclared` counts it, `isGlobalUnique` does not). Bare + `true` stays valid indefinitely; official examples and scaffolding emit + `'organization'` in new code (non-normative, ADR-0120 Resolved #2). + - **Declared-index** contract is now stated per word (ADR-0120 D1, amending + #3696): `'global'` = today's verbatim behavior — materialized over exactly + `fields`, no organization column injected; `'organization'` = the driver + prepends the NULL-safe organization key part + (`COALESCE(organization_id, '__global__')`, ADR-0120 D3) at registration — + materialization lands with #5030's driver PR, which this change must follow; + bare `true` = the deprecated positional spelling of `'global'` — warned in + 17.x by lint `unique/unscoped-declared-index`, rejected at protocol 18 + (#5082). + - **Rejected words carry the fix**: `'tenant'` and `'org'` are not accepted and + are not aliases — the parse error names `'organization'` (ADR-0120 + §Terminology). + - New export `isOrganizationUnique` — detects the explicit `'organization'` + spelling, single source of truth for the declared-index distinction across + SQL/Mongo index sync. + - The `UniqueScopeSchema` doc block's false single-tenant exemption ("the + tenant column is constant, so the composite index degenerates to the + single-column one" — falsified by #5030: the constant is NULL and SQL UNIQUE + is NULL-distinct) is replaced with the D3 truth (NULL bucket + COALESCE). + +### Patch Changes + +- c142ced: docs(spec,skills): the docs and the `objectstack-api` skill catch up with the endpoint executor (#5238) + + The executor landed (#5040 E1–E8) and the blanket refusal of a non-empty `apis:` + became five per-endpoint publish gates — but three prose surfaces still told the + reader the opposite. That is worse than stale: each one is an _instruction_ an + upgrading author (very often an AI maintainer with nothing but that text) would + follow away from a capability that now works. + + **Two prescription texts in `packages/spec` — the shipped half of this change.** + + - `App.apis`'s `retiredKey()` tombstone said the stack-level `defineStack({ apis })` + it redirects to "is ALSO not executable in v17 (#4936) … until the endpoint + executor ships". A tombstone that redirects somewhere has to be true about the + place it points at; this one sent the author to a surface it called dead in the + same sentence, so the natural next move was to keep serving the route from + handler code. It now says the surface EXECUTES from protocol 17, names the five + gates, and carries the two things to get right when moving a declaration up a + level: the `/api/v1/apps//` carve-out with an explicit + `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaulting to `true` + with ADR-0121 D6's armed-`rateLimit` pairing on an explicit `false`. The removal + half is untouched — `App.apis` was never read and is still gone — and #4936 is + still named, because the history is why the redirect exists at all. + - The `defineStack({ server })` module header said #5040 "wires endpoint-level + `rateLimit` — still unwired today". It is wired. The header now also states the + relationship an author of the server-level budget actually needs: endpoint + buckets are keyed in their own namespace, so the two budgets meter + independently rather than sharing a counter. + + Both feed `content/docs/references/` through `gen:docs`; those two pages move with + them and nothing else does. + + **Hand-written docs.** `protocol/kernel/http-protocol.mdx` traded its "that surface + has no executor" callout for a real **Declarative Endpoints** section: the serving + chain (match → policy chain → delegation to the same pipelines the built-in routes + use), the five gates, the policy answers (401 / 429 + `Retry-After` / + `Cache-Control: private, max-age=` on successes only), and the identity that is + easy to get wrong — an unmatched path, and a **method mismatch on a declared path**, + both keep the transport's bare `404` byte for byte, because the seam is Hono's + `notFound` and not a registered route, so there is no method set to report a `405` + over. `getting-started/quick-reference.mdx` gains the compact lookup entry. + + **The `objectstack-api` skill** stopped describing `ApiEndpointSchema` as a + four-arm `type` union with a `target` and gained a section that teaches the current + capability: when `apis:` beats `contributes.routes` (and when it does not — real + handler code), the carve-out, the gates as _things to run `objectstack validate` + for_ rather than texts to memorise, D6 with its `enabled === true` predicate spelled + out, and the mapping keys' minimal semantics (projection by dot path; no + `transform`; no `inputMapping` on a bodyless operation). It points at the + `declarative-apis-endpoints-live` upgrade entry rather than restating the review, + so there is one source of truth for it. + + Every claim above was measured against the built spec rather than believed: the + doc example publishes, the `authRequired`-omitted shape resolves to `true`, and the + `rateLimit` written without `enabled: true` beside `authRequired: false` is refused + with its prescription. The tombstone's new text is pinned two-sided (it must say + the new thing AND must not say the retired one, with `#4936` still present) so a + regression cannot pass by emptying the message. + +- d9971d3: fix(driver-sql): `$field` 跨字段比较改为按 ADR-0112 响亮拒绝,不再抛裸 TypeError + + `{ amount: { $gt: { $field: 'budget' } } }`(spec `FieldReferenceSchema`,由 `compileCelToFilter` 在转译含字段间比较的 CEL 权限/RLS 规则时产出)此前被 SqlDriver 当作**绑定值**交给驱动,sqlite 抛出无 `code`、无 `status` 的裸 `TypeError` —— 落在 `INVALID_FILTER` 信封之外,到客户端表现为不透明的服务端错误。更隐蔽的是列表位置:`$in` / `$between` 里的 `$field` 成员连报错都没有,直接静默返回零行。 + + 现在两者都以完整信封拒绝(`error.code = INVALID_FILTER`、HTTP 400、无 `[sql-driver]` 前缀),报错点名字段、运算符与被引用字段,并说明跨字段比较**当前仅内存求值路径(`matchesFilter`)支持**。三个比较发射点统一处理,Filter Protocol 与数组三元组两种写法得到同一答案。 + + 同一处闸门补上了 issue 指出的通用臂:**已知运算符 + 无法绑定的值形态**(标量比较位上的普通对象 / 数组)此前同样是裸 `TypeError`,现在也返回 `INVALID_FILTER`。`$in` / `$nin` / `$between` 的正常数组绑定不受影响。 + + `FieldReferenceSchema` 声明保留,JSDoc 补注执行支持面(内存求值 ✅ / SQL 下推 ❌ 响亮拒绝);SQL 列对列编译实现见 #5222。 + +- 471839d: fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956) + + The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live` + verdict plus a `note` asserting that the per-widget props were _"classified in + the DashboardWidgetSchema subtree"_. **No such subtree ever existed.** The gate's + walk drills one level and only through an explicit `children`, and `widgets` + declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema` + were never classified, never counted as unclassified, and every run printed + "all governed-type properties are classified" anyway. + + That gap — not evidence — is what carried `widgets[].responsive` through the + #3896 inert-key sweep that removed both its sibling `widgets[].performance` and + its literal namesake `view.responsive`. `view` is drilled through `children`, so + `list.responsive` got asked and went out; `widgets` was never asked. It was + finally retired in #4876 / PR #4995, by hand, four days late. + + **What changed for authors** + + The `objectstack build` / `objectstack lint` advisory now covers dashboards, so + five widget keys warn at build time (they never did before — `dashboard` was not + in the lint's type collections, because until now its ledger warned on nothing): + + | Widget key | Why it warns | What to do instead | + | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------- | + | `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance | + | `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` | + | `widgets[].actionType` | pairs with the above | as above | + | `widgets[].actionIcon` | zero readers in either repo | as above | + | `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` | + + Advisory only — the build never fails on these. **Nothing is removed and no + runtime behaviour changes**: this records verdicts, it does not act on them. + Enforce-or-remove (ADR-0049) for the five is tracked separately. + + Two verdicts worth knowing because they cut the other way: `requiresService` is + **live** — it reads as inert in the renderer repo but the REST layer strips + widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live + on the inline chart path only; on the ADR-0021 dataset path the string arms are + dropped and `{ offset }` fails in the analytics executor. + + **What changed for the gate** + + `pnpm --filter @objectstack/spec check:liveness` gains a third direction. A + ledger entry sitting on a container property must now declare one of exactly + three dispositions, all of them data: **drilled** (`children`), **deferred** (a + `{ container, to }` row naming the coordinate that does classify the subtree), + or **recorded** (a row in the shrink-only + `scripts/liveness/undrilled-containers.baseline.json`). A container in none of + the three fails, and so does a baseline row whose container has since been + drilled. + + A deferral is **resolved, not believed** — the target must exist (a governed + type root, or a drilled `type/prop` coordinate) and classify exactly the + container's child keys; a dangling or drifted target fails. That is the #4956 + claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema` + now produces a build failure naming it, where the same words in a `note` were + believed for a release. + + Every run reports both populations (today: 58 containers / 292 child keys + classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled` + prints the worklist, and the success line no longer claims a completeness it + does not have. + +- 94f7b6a: fix(spec,runtime): `EngineSchemaRegistryView` now declares the six package-lifecycle members it always had (#4311). + + `getPackage` / `installPackage` / `uninstallPackage` / `enablePackage` / `disablePackage` / + `updatePackageManifest` are additions to the exported `EngineSchemaRegistryView` type only — + `SchemaRegistry` has implemented all six since long before the contract existed, and three + packages outside the engine already call them (`runtime`'s `/packages` domain handler, + `metadata-protocol`'s install/update primitives, `service-package`'s hydration). The contract + landed in #4404 declaring eight members; these six were missed, and nothing caught it because + `@objectstack/runtime` had no `typecheck` script to read the caller. Zero runtime behaviour + change: no implementation, call site or response shape moves. + + `@objectstack/runtime` itself is not released by this change — it gains a `typecheck` script and + loses its `check-type-check-coverage` DEBT entry, plus type-only annotations (unused parameters + renamed to `_`-prefixed, one unused import dropped). + +- ba98e26: tooling: strictness 台账「数字/散文分家」—— 计数转生成物走 os-regen,判定与依据保持手写 (#5107, #5072) + + `docs/audits/2026-07-unknown-key-strictness-ledger.md` 是战役期间全仓最热的合并冲突点,而冲突全部落在它的**数字**上:两个批次各按自己那份正确的增量去减表头,git 把互不重叠的**行**干净合并,而与谁都不冲突的**小计行**「干净合并、两边都错」—— 单日 7 例,`ui/` 小计被三个批次分别写成 119 / 110 / 100,而合并后的正确值 91 三边都没写过。**散文当天只冲突过一次,而且那次是有意义的。** + + 所以数字走了。新增生成物 `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md`(`gen:strictness-ledger`),承载每文件站点数/strip 数、各段表头、按类小计、posture 分布与未细分目录的总数;`.gitattributes` 把它加进 `merge=os-regen`(#4675),合并时不做文本合并、整体重生成,`pre-commit` 在重生成之前拒绝提交。台账本体只留承重的部分:`Class` 判定、依据、findings log、豁免记录 —— 这些是判断,不是算术,重生成会**删掉别人的证据**,所以 `docs/audits/**` 仍在 `NOT_DRIVER_MANAGED` 里。 + + `check:strictness-ledger` 的职责随之反转,双向闸语义完整保留: + + - **生成物新鲜**:整份重新渲染后逐字节比对,失败信息把「某个数字动了 = 有 schema 在没人重新审视的 `Class` 判定下被增删/改姿态」这句话说出来 —— 这正是旧的手写计数唯一值得留下的那一半; + - **散文自洽**:每条手写行必须指向一个仍然存在、仍然有站点的文件(旧的计数检查顺带买到的性质),有站点却没有行仍然红,strip 行归零仍然红(反向钉)。 + + 两条红路径都先证了红再信绿:改生成物里一个数字 → EXIT=1;删一条手写行 → EXIT=1(并且被删的行不会静默变成 0,它会以 `⚠️ unclassified` 出现在生成物里)。 + + 小计是「对判断做算术」,所以 remaining-strip 那张表的 `Class` 单元格现在有语法:` [(p)] [· , …]`。已解决的 `mixed`/`split` **必须**声明自己的拆分,闸门拒绝猜 —— 一个宽容的解析器会把这些数字原封不动地送回它们刚被搬走的地方,而且是在一个绿色的文件里。迁移后所有已发布的数字逐一复现(235 strip / 36 open files / authorable 29 / unresolved 33 / no door 38 / no gate 31)。 + + **同一把尺的搭车修复(#5072)**:`postureOf()` 对战役自己的 helper 短路 —— `strictObject(` 直接返回 `strict`,不看链上挂了什么,于是 `strictObject(…).passthrough()`(运行期**开放**的形状)在台账里被记成 **strict**。全仓恰好 2 处,都在 `ui/view.zod.ts`(`GanttConfigSchema` / `TreeConfigSchema`),两处的 `.passthrough()` 都是刻意的。现在 idiom 只决定**起始**姿态,链一律走完,最后一个显式调用赢。`ui/` 的 strict 读数 119 → 117、passthrough 3 → 5;**strip 计数不变**,所以 remaining-strip 那张双向表的数字一个都没动。零 `*.zod.ts` 语义改动。 + +- 8064b07: **A view filter rule carrying the console's UI row `id` no longer 422s (#5114).** + + `ViewFilterRuleSchema` had been closed to unknown keys by an earlier strictness + wave. The filter builder the console renders stamps `id: crypto.randomUUID()` on + every filter row it creates (a React list key), and the metadata write path + validates the PUT body and then persists the **authored** body verbatim — so the + `id` is on the wire, and in already-stored view rows. Closed, the schema rejected + it: every filter write carrying one came back `422 Unrecognized key(s) on this +view filter rule: 'id'`. Measured on all three paths, including the flattened + personalization overlay that is the shape the console PUTs. + + ⚠️ **This does not on its own restore "save a filter from the console".** Browser + verification found a second, independent defect stacked on the same request: the + list toolbar persists the filter builder's whole `FilterGroup` object (`{ id, +logic, conditions }`) into `filter`, where the spec declares `ViewFilterRule[]` — + a type mismatch that rejects before the `id` is ever reached. That one belongs to + the producer and is tracked separately; until it lands, the console's filter save + still fails. What this change fixes is every writer that sends a well-formed + `ViewFilterRule[]` whose rows carry the UI `id` — including view rows already + stored with one. + + The shape is reopened (unknown keys are dropped again, as before the closure). + `id` is deliberately **not** declared: it is a UI artifact, and declaring it would + put it on the authorable surface and tell an AI author to generate a UUID for a + filter rule. Nothing else changed — the operator vocabulary, the legacy-spelling + normalization and the required `field` all still validate, so an invented operator + is still rejected. + + Worth knowing for anyone tightening a neighbouring block: **`.strip()` does not + recurse**, any more than `.strict()` does. `ViewMetadataSchema` re-opens its + flattened members so Studio's round-trip keys ride along, but that re-opens the + top level only — a nested block closed inside the list view is still reached + through that member, so a console-stamped key inside it 422s regardless. The + durable fix is the authoring/wire split tracked in #5074, which this site is now + named in; the verdict is recorded on the schema, in + `view-filter-rule-wire-id.test.ts`, and in the `ui/` row of the strictness ledger. + +- 4a56dbd: fix(spec): 视图标签 / 描述现在能真正解析出译文(#4854) + + `resolveViewLabel` / `resolveViewDescription` 读取的两个字段,运行时实际下发的 + 视图文档一个都没有,因此**任何按正常方式(`defineView`)编写的视图,标签永远 + 落回英文字面量**,无论翻译包里写了什么。列表视图切换器横在每个对象列表页顶部, + 所以在纯中文部署里,这是屏幕上最显眼的一处残留英文。 + + 两处失配互相独立,任何一处都足以让解析失败,现已一并修复: + + 1. **对象名取不到。** 旧代码读 `view.objectName ?? view.data?.object`;而 + `GET /api/v1/meta/view?object=…` 下发的文档把对象放在**顶层 `object`**, + 授权配置嵌在 `config` 下。于是 `objectName` 为 `undefined`,函数在 + `if (!bundle || !objectName)` 处就返回了字面量,根本没走到查找。 + 现在按 `objectName → object → data.object → config.data.object` 依次取值, + 与 i18n 提取器(`packages/cli/src/utils/i18n-extract.ts`)判定对象的顺序 + 一致 —— 写 `_views` 键的那一端和读它的这一端,从此对"哪个字段代表对象" + 有相同答案。 + 2. **查找键也是错的。** 旧代码用 `view.name` 直接查;而下发文档的 `name` 是 + 注册表分配的全局唯一身份 `.`(如 + `crm_account.account_gallery`),翻译包按**裸键**存放 + (`objects.._views..label`)。现在查找前先剥掉 + `.` 前缀 —— 这是对 `expandViewContainer` 组装规则的**反解**,不是 + 容错别名;没有前缀的名字(手工构造的视图)原样使用,行为不变。 + + **非破坏性。** `ViewLike` 仅新增两个可选字段(`object`、`config`),既有调用 + 方式全部照旧;之前能解析的场景没有一个改变结果 —— 在此之前,下发文档这条路径 + 上本就没有任何东西能解析成功。应用侧无需改动:`_views` 的键仍然是编写视图时 + 用的裸键。 + + 已知遗留(不在本次修复范围,另行跟踪):只声明了默认 `list`(没有 `listViews`) + 的容器仍解析不出译文 —— 提取器写的键是 `list`,而组装器给它的注册名是 + `.default`。这是两个**生产方**之间的分歧,须在生产端统一,不能靠消费端 + 再加一层兼容。 + ## 17.0.0-rc.2 ### Major Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 2eed9e5cfd..ff666ecfb4 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", @@ -212,7 +212,7 @@ "check:empty-state": "tsx scripts/liveness/check-empty-state.mts", "check:variant-docs": "tsx scripts/check-variant-docs.mts", "gen:strictness-ledger": "tsx scripts/build-strictness-ledger-counts.mts", - "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", + "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", "check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check", "check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 8f438683a9..840f151ff0 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/trigger-api +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index c4fda9d7d3..f70842a855 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.2", + "version": "17.0.0-rc.3", "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 f489c99545..92e9827eb1 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/plugin-trigger-record-change +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index e95ab110c8..9602643d90 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.2", + "version": "17.0.0-rc.3", "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 ccdac0105b..64dd336001 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/plugin-trigger-schedule +## 17.0.0-rc.3 + +### Patch Changes + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [471839d] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index f4893f5d5f..4695c676bb 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.2", + "version": "17.0.0-rc.3", "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 38b25378a2..c1c84cfa89 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,142 @@ # @objectstack/types +## 17.0.0-rc.3 + +### Minor Changes + +- 02dc076: feat(types,cli,verify)!: 只解析 host app 声明过的包 —— `NODE_PATH` 不再算数,ADR-0093 D5 那道墙从此与启动方式无关 (#4719) + + **问题:契约写下了,但从没被检查过。** `@objectstack/types/node` 的 + `createHostRequire` 返回一个 CJS `createRequire`,而 CJS 解析认 `NODE_PATH` + (`Module.globalPaths`)。pnpm 生成的 bin shim 第一件事就是 + `export NODE_PATH=/node_modules/.pnpm/node_modules`,于是任何被工作区里 + **任意一个包**传递依赖到的包都能"从 host app 解析成功" —— 跟这个 app 声明了什么毫无关系。 + + 实测(cloud `apps/objectos-ee`,当时未声明 `@objectstack/organizations`): + `pnpm start`(经 shim)boot 成功、插件表里有 `Organizations`、ADR-0093 D5 一声不吭; + `node node_modules/@objectstack/cli/bin/run.js serve`(不经 shim)则 + `✖ FATAL: tenancy posture 'isolated' was requested…` 并 exit 1。同一个 app、同一份 + `package.json`、同一个 posture,**只因为进程是怎么被拉起来的**,走出两种结果。 + 而 D5 的报错一直在教 operator "declare it in the app's package.json" —— 那正是 + CLI 从来没检查过的那件事。 + + **改法:声明即执行。** 解析前先读 `/package.json`;只有包名出现在 + `dependencies` / `devDependencies` / `optionalDependencies` / `peerDependencies` + 的 **键**里,才去 host 的 `node_modules` 里查它。仅仅"能被解析到"不再算数 —— + 那正是让契约失效的那个偶然。未声明的包退回到 importing package 自身的解析 + (ESM,不认 `NODE_PATH`),框架自有的包加载路径不受影响。 + + **两种失败从此分开报。** 今天它们都塌成同一条 `MODULE_NOT_FOUND`,补救办法却相反: + + - **未声明** —— 指向"在 app 的 `package.json` 里声明并安装",并说明为什么 + hoisting / `NODE_PATH` 不被接受; + - **声明了但解析不到** —— 明确说这是**安装**问题(`pnpm install`、生产 prune + 砍掉了它、dist 没构建),别再让人回去重看那份已经写对的 `package.json`。 + + 分类经新导出的 `hostImportFailureKind(err)` 暴露给调用方;两种错误都仍带 + `code: 'MODULE_NOT_FOUND'`,`isModuleNotFoundError` 的既有判定不变。 + + **BREAKING — 哪类部署会从假绿变红,以及怎么修。** + + 1. **靠 hoisting 苟着的部署。** 一个 app 请求了 walled tenancy posture + (`OS_TENANCY_POSTURE=group` / `isolated` 或 `OS_MULTI_ORG_ENABLED=1`)、 + 却没在自己的 `package.json` 里声明 `@objectstack/organizations`,过去经 pnpm + shim 启动能正常 boot —— 现在会命中 ADR-0093 D5 并 exit 1。 + **修法:在那个 app 的 `package.json` 里声明该依赖并安装。** + 这些部署本来就在未声明状态下运行,红的是一直存在的事实,不是新引入的故障: + 同一个 app 不经 shim 启动今天就已经是 exit 1。 + (同样适用于 `@objectstack/service-ai` / `@objectstack/service-ai-studio`,以及 + `bootStack({ multiTenant: true })`、dogfood 的 enterprise 门。) + + 2. **`createHostImporter` 的签名变了**,因为它现在需要 host 的**根目录**才能读到 + 那份 manifest,而一个 `NodeRequire` 无法被问出它锚在哪里: + + ```diff + - createHostImporter(createHostRequire(hostRoot)) + + createHostImporter(hostRoot) // 省略参数 = process.cwd(),同旧默认 + ``` + + `createHostRequire` 本身保持不变,仍然导出。 + + 新增导出(`@objectstack/types/node`):`HOST_DECLARATION_FIELDS`、 + `HostDeclarationField`、`HostDeclaration`、`readHostDeclaration`、 + `isDeclaredByHost`、`packageNameFromSpecifier`、`HostImportFailureKind`、 + `HOST_IMPORT_FAILURE_KIND`、`hostImportFailureKind`。 + +### Patch Changes + +- 08f93bc: fix(auth): `organization/create` gates on the authoritative `OS_TENANCY_POSTURE`, not the demoted `OS_MULTI_ORG_ENABLED` (#5233) + + A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or + `group`), legacy boolean unset — mounted the entire organization wall and still + answered `403 Creating additional organizations is disabled on this deployment.` + to `POST /api/v1/auth/organization/create`. Org-less users had no way to create + their workspace, so the guided "Create your workspace" path was a dead end. + + ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted + `OS_MULTI_ORG_ENABLED` to a back-compat _input_ of `resolveTenancyPosture()`. + Two sites in `AuthManager` kept reading the demoted boolean directly, so both + reported "single-org" on a deployment that had asked for a wall and got one: + + - `organizationHooks.beforeCreateOrganization` — the 403 above. It now judges + `postureEnforcesWall(resolveTenancyPosture())`, matching the knob `serve.ts`'s + own ADR-0093 D5 boot guard keys on. Intent is unchanged (single-org still + refuses); only the knob is corrected. + - `/auth/config`'s `features.multiOrgEnabled` — its no-tenancy-service fallback + read the same boolean. It now falls back to the resolved posture, so a lean + embedding advertises the capability its own gate allows. + + **No configuration change is needed anywhere.** Deployments that set only + `OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` + falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` + workaround people used to unblock themselves stays valid. Deployments that set + only `OS_TENANCY_POSTURE` can now drop the redundant boolean. + + `resolveMultiOrgEnabled()`'s doc comment in `@objectstack/types` — which still + instructed "the auth manager's `/auth/config` feature flag and org-create guard + … MUST call this", written before the demotion — now says the opposite: ask the + posture, and never gate on this boolean. Its behaviour is unchanged. + +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [2e284b2] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [c142ced] +- Updated dependencies [c52e608] +- Updated dependencies [77be690] +- Updated dependencies [d9971d3] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [471839d] +- Updated dependencies [26e1029] +- Updated dependencies [108ba8d] +- Updated dependencies [4845f85] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/spec@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 2e2b603201..ced7e44b99 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 467fec5a35..a68e2d1788 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,223 @@ # @objectstack/verify +## 17.0.0-rc.3 + +### Minor Changes + +- 02dc076: feat(types,cli,verify)!: 只解析 host app 声明过的包 —— `NODE_PATH` 不再算数,ADR-0093 D5 那道墙从此与启动方式无关 (#4719) + + **问题:契约写下了,但从没被检查过。** `@objectstack/types/node` 的 + `createHostRequire` 返回一个 CJS `createRequire`,而 CJS 解析认 `NODE_PATH` + (`Module.globalPaths`)。pnpm 生成的 bin shim 第一件事就是 + `export NODE_PATH=/node_modules/.pnpm/node_modules`,于是任何被工作区里 + **任意一个包**传递依赖到的包都能"从 host app 解析成功" —— 跟这个 app 声明了什么毫无关系。 + + 实测(cloud `apps/objectos-ee`,当时未声明 `@objectstack/organizations`): + `pnpm start`(经 shim)boot 成功、插件表里有 `Organizations`、ADR-0093 D5 一声不吭; + `node node_modules/@objectstack/cli/bin/run.js serve`(不经 shim)则 + `✖ FATAL: tenancy posture 'isolated' was requested…` 并 exit 1。同一个 app、同一份 + `package.json`、同一个 posture,**只因为进程是怎么被拉起来的**,走出两种结果。 + 而 D5 的报错一直在教 operator "declare it in the app's package.json" —— 那正是 + CLI 从来没检查过的那件事。 + + **改法:声明即执行。** 解析前先读 `/package.json`;只有包名出现在 + `dependencies` / `devDependencies` / `optionalDependencies` / `peerDependencies` + 的 **键**里,才去 host 的 `node_modules` 里查它。仅仅"能被解析到"不再算数 —— + 那正是让契约失效的那个偶然。未声明的包退回到 importing package 自身的解析 + (ESM,不认 `NODE_PATH`),框架自有的包加载路径不受影响。 + + **两种失败从此分开报。** 今天它们都塌成同一条 `MODULE_NOT_FOUND`,补救办法却相反: + + - **未声明** —— 指向"在 app 的 `package.json` 里声明并安装",并说明为什么 + hoisting / `NODE_PATH` 不被接受; + - **声明了但解析不到** —— 明确说这是**安装**问题(`pnpm install`、生产 prune + 砍掉了它、dist 没构建),别再让人回去重看那份已经写对的 `package.json`。 + + 分类经新导出的 `hostImportFailureKind(err)` 暴露给调用方;两种错误都仍带 + `code: 'MODULE_NOT_FOUND'`,`isModuleNotFoundError` 的既有判定不变。 + + **BREAKING — 哪类部署会从假绿变红,以及怎么修。** + + 1. **靠 hoisting 苟着的部署。** 一个 app 请求了 walled tenancy posture + (`OS_TENANCY_POSTURE=group` / `isolated` 或 `OS_MULTI_ORG_ENABLED=1`)、 + 却没在自己的 `package.json` 里声明 `@objectstack/organizations`,过去经 pnpm + shim 启动能正常 boot —— 现在会命中 ADR-0093 D5 并 exit 1。 + **修法:在那个 app 的 `package.json` 里声明该依赖并安装。** + 这些部署本来就在未声明状态下运行,红的是一直存在的事实,不是新引入的故障: + 同一个 app 不经 shim 启动今天就已经是 exit 1。 + (同样适用于 `@objectstack/service-ai` / `@objectstack/service-ai-studio`,以及 + `bootStack({ multiTenant: true })`、dogfood 的 enterprise 门。) + + 2. **`createHostImporter` 的签名变了**,因为它现在需要 host 的**根目录**才能读到 + 那份 manifest,而一个 `NodeRequire` 无法被问出它锚在哪里: + + ```diff + - createHostImporter(createHostRequire(hostRoot)) + + createHostImporter(hostRoot) // 省略参数 = process.cwd(),同旧默认 + ``` + + `createHostRequire` 本身保持不变,仍然导出。 + + 新增导出(`@objectstack/types/node`):`HOST_DECLARATION_FIELDS`、 + `HostDeclarationField`、`HostDeclaration`、`readHostDeclaration`、 + `isDeclaredByHost`、`packageNameFromSpecifier`、`HostImportFailureKind`、 + `HOST_IMPORT_FAILURE_KIND`、`hostImportFailureKind`。 + +- de113a4: BREAKING(auth): `organization/create` 改判**实际生效的** tenancy posture —— 没有组织墙的部署不再能创建组织 (#5261) + + `POST /api/v1/auth/organization/create` 的闸门此前判的是操作者**请求的** posture + (`postureEnforcesWall(resolveTenancyPosture())`,一次纯 env 读)。现在判 `tenancy` 服务给出的 + **生效** posture —— `tenancy?.posture ?? resolveTenancyPosture()`,与 `/auth/config` 的 + `features.multiOrgEnabled` 是**同一次求值**。 + + ## 为什么 + + 两个站点此前只在一种形状下分叉,而那种形状恰恰是最不该放行的一种 —— ADR-0093 D5 **降级态**: + 请求了 `isolated`/`group`,但企业包 `@objectstack/organizations` 缺席,于是 `tenancy.posture` + 解析为 `single` 且 `degraded=true`。此时: + + - 闸门读「请求」→ **放行**; + - `/auth/config` 读「生效」→ `multiOrgEnabled=false`,console 把「创建组织」入口**藏起来**。 + + 结果是 UI 没有按钮而 API 打得通,并且建出来的每一个组织都是**没有任何引擎强制的租户边界** —— + 声明了但没强制,ADR-0049 最讨厌的那一类,只不过发生在部署层。改判生效 posture 之后两者同解、 + 永不分叉:**没有墙,就没有组织**,无论这个部署是从未要过墙,还是要了没拿到。 + + ## 破坏性影响(有意为之) + + **没有安装企业包 `@objectstack/organizations` 的部署将完全无法创建组织**,任何 env 组合都不行 —— + `OS_TENANCY_POSTURE=isolated`、`OS_MULTI_ORG_ENABLED=true`、两个一起设,都不再能把闸门说通。 + 这是一次实打实的能力收缩,不是 knob 纠正,所以搭 v17 主版本车。 + + | 部署形状 | 改前 | 改后 | + | ------------------------------------------------- | ------ | ------------- | + | 有企业包,posture `isolated` / `group`(墙真的立着) | 200 | **200**(不变) | + | **请求了墙但企业包缺席(D5 降级态)** | 200 | **403** ⚠️ | + | `single` / 两个 knob 都不设 | 403 | 403(不变) | + | 未注册 `tenancy` 服务的精简嵌入(回落 env 解析) | 按 env | 按 env(不变) | + + `serve.ts` 本来就在降级态**默认拒绝启动**(要 `OS_ALLOW_DEGRADED_TENANCY=1` 才走),所以这条收缩 + 命中的是一个已经需要显式选择才能到达的形状:从此那里的 org-create 路由也一并拒绝,而不是半通不通。 + cloud 控制面与任何装了企业包的部署不受影响。 + + **迁移**:需要多组织能力的部署安装并声明 `@objectstack/organizations`(ADR-0081 D2)。仅靠 env + 声明一个墙、而没有实现它的运行时,不再被当作多组织部署对待。 + + ## `@objectstack/verify`(minor,新增) + + `BootOptions.multiTenant` 增加 `'posture-only'` 取值:注册一个内置的 `org-scoping` 服务替身, + 让 `tenancy` 服务解析出真实、**非降级**的 `isolated` posture,从而打开受 posture 把守的路由 —— + 供那些「组织墙是**前置条件**而非被测对象」的 fixture 使用(#3624 的 `org-create-default-team` + dogfood 就是为它而建:那条回归此前靠「boot 后翻 env、闸门 live 读」开路,本次收缩把这个绕法关死了)。 + + ⛔ 它**不做任何租户隔离**:不 stamp `organization_id`,不 scope 任何查询 —— 它让部署的 + **posture** 为真,不是让**墙**为真。跨租户隔离的唯一诚实证明仍然是 `multiTenant: true` + + 真实的企业包,这也是那些 gate 在本仓继续 skip 而不是假装通过的原因。 + +### Patch Changes + +- Updated dependencies [f522e95] +- Updated dependencies [29c6c9d] +- Updated dependencies [d21c001] +- Updated dependencies [7cf1531] +- Updated dependencies [586d6f7] +- Updated dependencies [2e284b2] +- Updated dependencies [3905c00] +- Updated dependencies [4335497] +- Updated dependencies [0161c7f] +- Updated dependencies [a019e52] +- Updated dependencies [64fc6d5] +- Updated dependencies [eaaf03c] +- Updated dependencies [d17df80] +- Updated dependencies [7d0e7b5] +- Updated dependencies [1f0e7cb] +- Updated dependencies [c142ced] +- Updated dependencies [2b63a00] +- Updated dependencies [18b8eaa] +- Updated dependencies [9c4f174] +- Updated dependencies [78adc2e] +- Updated dependencies [81e2744] +- Updated dependencies [277eb36] +- Updated dependencies [41e605e] +- Updated dependencies [2649ccb] +- Updated dependencies [a70cd0a] +- Updated dependencies [c52e608] +- Updated dependencies [afa6aa5] +- Updated dependencies [77be690] +- Updated dependencies [08f93bc] +- Updated dependencies [d9971d3] +- Updated dependencies [eb3e650] +- Updated dependencies [02dc076] +- Updated dependencies [175d789] +- Updated dependencies [55dbbba] +- Updated dependencies [e98fb14] +- Updated dependencies [5d3ced9] +- Updated dependencies [9fa6bab] +- Updated dependencies [0f2fdcd] +- Updated dependencies [8ffa8b9] +- Updated dependencies [674ac99] +- Updated dependencies [1b9a53b] +- Updated dependencies [7c2f7dd] +- Updated dependencies [471839d] +- Updated dependencies [8597a7d] +- Updated dependencies [ff39e63] +- Updated dependencies [c5adfe1] +- Updated dependencies [26e1029] +- Updated dependencies [1cae606] +- Updated dependencies [4addd9d] +- Updated dependencies [108ba8d] +- Updated dependencies [1203bb2] +- Updated dependencies [de113a4] +- Updated dependencies [caf144a] +- Updated dependencies [5b8f95b] +- Updated dependencies [2ddba89] +- Updated dependencies [37a8f2b] +- Updated dependencies [7f955e5] +- Updated dependencies [4845f85] +- Updated dependencies [bf1edef] +- Updated dependencies [94f7b6a] +- Updated dependencies [c5a5996] +- Updated dependencies [b40f81c] +- Updated dependencies [82a06af] +- Updated dependencies [6e66cbe] +- Updated dependencies [c272e48] +- Updated dependencies [db2ea82] +- Updated dependencies [41c3b48] +- Updated dependencies [f61c8cf] +- Updated dependencies [5278e11] +- Updated dependencies [ba98e26] +- Updated dependencies [f104bab] +- Updated dependencies [f8cfbb4] +- Updated dependencies [1e6ab15] +- Updated dependencies [c87ef70] +- Updated dependencies [3cb0618] +- Updated dependencies [32a0874] +- Updated dependencies [7055c22] +- Updated dependencies [785a748] +- Updated dependencies [3af0354] +- Updated dependencies [866ff16] +- Updated dependencies [5a85e67] +- Updated dependencies [c183a12] +- Updated dependencies [69a89ce] +- Updated dependencies [8064b07] +- Updated dependencies [4a56dbd] +- Updated dependencies [06df4fa] + - @objectstack/service-analytics@17.0.0-rc.3 + - @objectstack/spec@17.0.0-rc.3 + - @objectstack/core@17.0.0-rc.3 + - @objectstack/plugin-hono-server@17.0.0-rc.3 + - @objectstack/runtime@17.0.0-rc.3 + - @objectstack/plugin-auth@17.0.0-rc.3 + - @objectstack/service-settings@17.0.0-rc.3 + - @objectstack/objectql@17.0.0-rc.3 + - @objectstack/types@17.0.0-rc.3 + - @objectstack/platform-objects@17.0.0-rc.3 + - @objectstack/service-automation@17.0.0-rc.3 + - @objectstack/rest@17.0.0-rc.3 + - @objectstack/plugin-sharing@17.0.0-rc.3 + - @objectstack/plugin-security@17.0.0-rc.3 + - @objectstack/service-datasource@17.0.0-rc.3 + ## 17.0.0-rc.2 ### Minor Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 4e37d9649c..a3c9413ed1 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.0.0-rc.2", + "version": "17.0.0-rc.3", "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 6b4ccf50be..33562574d9 100644 --- a/packages/vscode-objectstack/CHANGELOG.md +++ b/packages/vscode-objectstack/CHANGELOG.md @@ -1,5 +1,48 @@ # objectstack-vscode +## 17.0.0-rc.3 + +### Patch Changes + +- cbc844e: fix(vscode): every contributed snippet expands to metadata the spec accepts — and a gate that keeps it that way (#4917) + + The extension's snippets are a metadata **producer**: whatever `os-view-grid` + expands to is the first `.view.ts` an author (human or AI) ever writes. Nothing + in this repo has ever parsed that output, so the snippets drifted out of the + spec in silence. An audit of all eight found **five** broken against + `@objectstack/spec` 17: + + | snippet | what was rejected | canonical form now | + | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | + | `os-view-grid` | `list.defaultSort`, `list.pageSize` (never declared on `ListViewSchema`); plus `type` / `objectName` on the **container**, which is the flat-view-where-a-container-goes mistake `ViewSchema`'s own guidance names | `defineView({ object, list: { …, sort: [{ field, order }], pagination: { pageSize } } })` | + | `os-flow` | node `name` / `next` (the keys are `label` + an `edges` array), and a top-level `trigger` block | `defineFlow` with the object binding on the START node's `config: { objectName, triggerType }` and an explicit `edges: []` | + | `os-agent` | `tools` — removed in protocol 17 (#3894) | `skills: []` | + | `os-stack` | `manifest` missing the required `id` and `type` | `{ id, namespace, version, type, name, engines }` | + | `os-field-lookup` | `reference: { object, labelField }` — `reference` is a plain object name | `reference: 'target_object'` + `displayField` | + + Separately, **all five** module snippets imported `{ Data }` / `{ UI }` / + `{ Automation }` / `{ AI }` from the package root. Those namespace re-exports + were removed for being untree-shakeable (see `packages/spec/src/index.ts`), so + the very first line of each scaffold did not resolve. They now import from the + subpath and author through the domain's validating factory — `ObjectSchema.create`, + `defineView`, `defineFlow`, `defineAgent`, `defineStack` — which parses at + authoring time and, being a _value_ import, fails loudly instead of degrading + to `any` (issue #2035's rationale, applied to the scaffolds themselves). + + **The recurrence is what actually got fixed.** `os-view-grid` broke because + #4001 closed `ListViewSchema` for unknown keys and no gate anywhere could see a + snippet body; the next strictness batch would have broken another one the same + way. The package now has a `test` script that expands every snippet, evaluates + it against the real spec, and `safeParse`s the authored literal with the schema + the runtime uses. Three independent failure modes are covered — the expansion + does not evaluate, the literal does not parse, or an import names a binding the + spec no longer exports — with a negative control asserting the pre-fix shape is + still rejected, a plan table that fails when a snippet arrives ungated, and a + lockstep check on the `engines.protocol` major so that stamp cannot rot either. + + No authoring change is required of anyone: this only replaces snippet output + that never validated. + ## 17.0.0-rc.2 ## 17.0.0-rc.1 diff --git a/packages/vscode-objectstack/package.json b/packages/vscode-objectstack/package.json index 7360ada987..e751598983 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.2", + "version": "17.0.0-rc.3", "publisher": "objectstack", "license": "Apache-2.0", "repository": {