From d5e76e2b09efe713c5f5d28a23f980acd97fe5c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:14:12 +0000 Subject: [PATCH] chore: version packages --- .changeset/advisory-boot-path-aggregation.md | 16 - .../analytics-icontains-per-dialect-fold.md | 19 - ...ytics-measure-result-type-string-family.md | 25 - .../analytics-measure-result-type-temporal.md | 24 - .../analytics-preview-min-max-operand-type.md | 25 - ...tics-text-family-case-exact-per-dialect.md | 18 - ...unknown-dialect-icontains-portable-fold.md | 21 - .../analytics-where-names-the-route-hop.md | 12 - .../api-duration-keys-unit-in-key-name.md | 95 - ...-plugin-flat-bundle-seed-double-collect.md | 32 - ...pproval-recall-docstring-override-scope.md | 20 - .changeset/approvals-bu-member-org-screen.md | 9 - .../approvals-continue-restored-suspension.md | 18 - .../artifact-packages-collection-reads.md | 65 - ...assembled-package-body-plugins-envelope.md | 59 - .../assignment-value-cel-envelope-executor.md | 71 - .../attest-fresh-datastore-remedy-register.md | 28 - ...ter-keyed-identity-and-listnames-parity.md | 62 - .../auth-catchall-owned-404-not-yielded.md | 23 - .../auth-domain-claim-segment-boundary.md | 13 - .../automation-completed-run-history-throw.md | 13 - ...tomation-failed-save-reseats-suspension.md | 12 - ...-row-unique-violation-metadata-protocol.md | 49 - .../blueprint-strict-mirror-value-parity.md | 15 - .changeset/boot-sign-in-report-remedy-text.md | 14 - .changeset/bulk-event-batch-organization.md | 51 - .changeset/cast-blob-compile-option-claim.md | 14 - .changeset/chart-config-missing-overreach.md | 26 - .changeset/chart-empty-selection-rules.md | 31 - ...hart-field-unknown-refused-binding-tier.md | 29 - ...-measure-unknown-presentation-positions.md | 20 - .../claim-capability-probe-before-mutate.md | 16 - ...lain-dashboard-refresh-interval-seconds.md | 11 - .changeset/cli-hook-body-subpath-export.md | 7 - .changeset/cli-lint-strict-warnings-fail.md | 25 - ...ion-audit-stamp-nullability-and-default.md | 7 - ...-package-publish-help-local-dev-example.md | 17 - .../client-oauth-family-wire-shape-binding.md | 65 - ...adme-analytics-automation-payload-reads.md | 9 - .../client-readme-retired-ai-methods.md | 11 - .changeset/colorfield-derive-describe.md | 15 - .changeset/compliance-families-retired.md | 165 -- ...ompose-merge-refuses-object-collections.md | 65 - .changeset/config-miss-refusal-to-stderr.md | 17 - .changeset/connector-error-mapping-retired.md | 112 - .changeset/console-a472b07167a3.md | 43 - .changeset/contained-failure-visibility.md | 16 - ...ore-private-keys-pin-extension-boundary.md | 44 - .changeset/core-time-zone-domain-repoint.md | 26 - .changeset/cron-dialect-row-names-croner.md | 7 - .changeset/dashboard-gap-author-vocabulary.md | 40 - .changeset/data-driver-aggregate-declared.md | 9 - ...egration-duration-keys-unit-in-key-name.md | 138 - .../datasource-admin-tenancy-posture.md | 9 - .../decision-predicate-envelope-refused.md | 16 - .../delete-data-request-schema-provenance.md | 9 - ...s-untyped-sweep-organization-forwarding.md | 9 - .changeset/diff-usage-error-to-stderr.md | 17 - ...scovery-subscribable-channel-definition.md | 22 - ...patcher-scope-strip-environments-prefix.md | 19 - .../dist-freshness-declaration-stamp.md | 13 - .../driver-memory-auto-save-interval-ms.md | 28 - ...memory-find-findone-create-honest-types.md | 15 - ...ver-memory-notcontains-non-string-value.md | 9 - .changeset/driver-mongodb-test-tsc-program.md | 57 - ...river-sql-text-operator-non-text-column.md | 11 - .changeset/driver-sql-update-declared-null.md | 11 - .changeset/driver-turso-config-timeout-ms.md | 41 - ...so-remote-text-operator-non-text-column.md | 7 - .../driver-turso-update-declared-null.md | 9 - ...d-error-developer-message-wire-spelling.md | 18 - .changeset/duplicate-source-must-be-a-base.md | 17 - .changeset/duration-unit-in-key-name.md | 82 - ...l-template-shared-read-decoration-strip.md | 14 - .../engine-refusals-stamp-httpstatus.md | 11 - ...ent-artifact-checksum-coverage-describe.md | 10 - ...tant-and-external-vocabulary-exemptions.md | 86 - .../es-ja-dashboard-gap-source-parity.md | 25 - ...aluated-expression-slot-requires-source.md | 59 - .../expression-source-non-string-refused.md | 17 - .changeset/field-value-domain-write-path.md | 65 - .../filter-text-non-string-stored-value.md | 15 - ...filter-text-operator-declared-type-door.md | 17 - .changeset/find-afterfind-array-guard.md | 22 - .changeset/flow-action-record-load-signal.md | 43 - .changeset/flow-edge-id-uniqueness.md | 64 - .changeset/flow-end-node-refused-outcome.md | 15 - .changeset/flow-node-id-uniqueness.md | 75 - ...low-record-decoupled-from-batch-payload.md | 55 - .../gantt-tree-config-close-passthrough.md | 55 - ...rated-migration-audit-stamp-timestamptz.md | 22 - .../generated-migration-id-column-shape.md | 13 - .changeset/great-clouds-repair.md | 7 - ...story-cleanup-failing-run-is-not-silent.md | 9 - .../history-cleanup-utc-retention-cutoff.md | 13 - .../hono-auth-mount-owned-404-not-yielded.md | 32 - .../hono-me-localization-user-locale.md | 31 - ...ost-importer-aliased-dual-publish-entry.md | 13 - ...18n-coverage-inline-locale-map-authored.md | 18 - .../i18n-declared-fallback-chain-rest.md | 27 - .../i18n-declared-fallback-chain-service.md | 11 - .../i18n-declared-fallback-chain-spec.md | 26 - ...tract-key-count-describes-emitted-bytes.md | 36 - ...xtract-metadata-forms-flag-independence.md | 22 - .changeset/i18n-walk-one-key-one-demand.md | 19 - .../import-row-unique-violation-rest.md | 44 - .changeset/inert-deadline-keys-retired.md | 140 - ...generate-emit-service-object-annotation.md | 26 - ...injected-system-column-labels-localised.md | 7 - ...install-local-admission-tenancy-posture.md | 9 - .changeset/job-service-replay-force.md | 15 - .../kernel-duration-keys-unit-in-key-name.md | 114 - .changeset/layer0-verdict-on-operation.md | 17 - .../layered-read-org-gate-after-fold.md | 15 - .../lint-eval-generator-load-envelope.md | 17 - ...lint-eval-throwing-generator-unscorable.md | 32 - .../lint-eval-unscorable-stack-json-face.md | 29 - .changeset/lint-generator-requires-eval.md | 13 - .../lint-non-record-collection-entry.md | 11 - .changeset/lint-non-record-objects-readers.md | 58 - .changeset/lint-readonly-create-scan-gap.md | 23 - ...list-view-grouping-server-side-contract.md | 63 - .changeset/lucky-doors-tickle.md | 20 - .changeset/lucky-poems-invite.md | 17 - .../magic-link-reads-recipient-locale.md | 36 - .../map-node-progress-state-lifetime.md | 13 - .changeset/mcp-stdio-tenancy-posture.md | 15 - .changeset/membership-ended-session-revoke.md | 41 - .../memory-analytics-date-range-timezone.md | 20 - .../memory-analytics-date-range-utc-window.md | 62 - .../memory-i18n-declared-fallback-locale.md | 21 - .changeset/metadata-ambiguous-stem-refused.md | 67 - .changeset/metadata-database-loader-ttl-ms.md | 23 - .../metadata-view-container-leaf-subpath.md | 59 - .../migrate-meta-chain-line-protocol-label.md | 17 - ...ification-event-migration-ledger-claims.md | 38 - ...otification-event-migration-run-receipt.md | 17 - ...object-door-searchable-listview-refusal.md | 24 - .changeset/object-graph-null-entry-guard.md | 10 - ...l-boot-loop-refuses-divergent-view-name.md | 48 - .changeset/objectql-hook-timeout-ms.md | 10 - ...ql-system-write-organization-recognizer.md | 18 - .changeset/olive-spiders-refuse.md | 13 - .changeset/org-hierarchy-timezone-columns.md | 26 - .../os-create-emits-an-installable-project.md | 29 - .../plain-unique-index-duplicate-preflight.md | 17 - .../platform-object-tenancy-census-derived.md | 13 - ...ects-dashboard-refresh-interval-seconds.md | 12 - .changeset/plugin-auth-find-envelope-limbs.md | 16 - .../plugin-describe-ui-type-spelling.md | 11 - .../plugin-dev-i18n-detect-packages-reader.md | 50 - ...curity-default-set-answer-not-container.md | 57 - .../plugin-security-scanner-ledger-entry.md | 42 - .changeset/plugin-security-scanner-retired.md | 83 - .changeset/plugin-sharing-field-recipient.md | 38 - .changeset/preview-column-enrichment.md | 19 - .../provenance-stamp-per-row-dispatch.md | 14 - ...lic-sharing-enabled-canonical-predicate.md | 13 - ...c-sharing-enabled-standing-policy-tsdoc.md | 11 - .../published-cli-stderr-nonblocking-guard.md | 15 - .changeset/quiet-pans-repair.md | 9 - .changeset/record-picker-filter-rule-array.md | 43 - ...reference-page-block-tag-payload-render.md | 34 - ...references-door-organization-forwarding.md | 9 - ...erences-door-refusal-envelope-converged.md | 27 - .../registry-conflict-code-constants.md | 19 - .../remote-loader-list-nameless-guard.md | 11 - .changeset/report-chart-axis-own-selection.md | 27 - .changeset/rest-api-config-consumes-parse.md | 11 - ...st-data-doors-compiled-against-protocol.md | 11 - .../rest-generic-passthrough-object-key.md | 60 - .../rest-translate-options-default-locale.md | 9 - ...runtime-declarative-row-update-executor.md | 43 - .../runtime-gate-stored-metadata-universe.md | 18 - .changeset/runtime-job-timeout-ms.md | 10 - .changeset/runtime-state-file-project-key.md | 24 - ...-tenancy-posture-failure-discrimination.md | 14 - ...box-writeback-entry-snapshot-normalised.md | 30 - ...scaffold-emission-policy-one-definition.md | 18 - .../schema-drift-single-value-json-column.md | 13 - .../scope-less-booted-row-attribution.md | 35 - .../score-metadata-lint-crash-visible.md | 17 - .../screen-flow-headless-satisfaction.md | 21 - .../seed-apply-read-back-decorations.md | 13 - .changeset/seed-read-drops-dead-org-rung.md | 13 - .../serve-unlinked-database-file-watch.md | 11 - ...analytics-text-operator-non-text-column.md | 7 - ...vice-datasource-turso-timeout-ms-reader.md | 57 - .changeset/service-job-timeout-ms.md | 11 - .../service-storage-test-tsc-program.md | 50 - ...session-payload-positions-security-axis.md | 102 - .../session-unbacked-org-claim-dropped.md | 9 - .changeset/session-user-language-retired.md | 63 - .../settings-admission-tenancy-posture.md | 9 - ...ings-door-value-domain-shared-predicate.md | 66 - .../share-link-admission-tenancy-posture.md | 11 - ...g-rule-evaluation-result-grants-refused.md | 33 - ...ignup-existing-address-explicit-refusal.md | 28 - .../single-kernel-tenancy-posture-provider.md | 48 - .../spec-compose-key-dispositions-export.md | 44 - ...spec-i18n-default-locale-authored-label.md | 22 - .../spec-notification-event-migration-id.md | 11 - .../stack-cross-reference-refusal-envelope.md | 16 - .../storage-file-read-tenancy-posture.md | 9 - ...rand-verdict-survives-bookkeeping-throw.md | 17 - .changeset/stranded-run-status-stamp.md | 63 - .../structural-condition-shape-refused.md | 17 - .changeset/studio-object-field-ref-refusal.md | 26 - .changeset/subflow-bubble-stranded-parent.md | 35 - ...y-backfill-recompute-undefined-on-empty.md | 57 - .../suspended-run-cache-consumed-elsewhere.md | 64 - .../sys-email-error-description-widen.md | 22 - ...sys-email-highlight-fields-to-addresses.md | 13 - .../system-duration-keys-unit-in-key-name.md | 110 - .changeset/tidy-cups-smile.md | 20 - .changeset/today-offset-one-calendar.md | 13 - ...lation-actions-convention-docblock-keys.md | 14 - .../translation-liveness-group-boundary.md | 28 - .changeset/tree-reference-self-only.md | 55 - .../truthful-stranded-decision-envelope.md | 17 - .changeset/try-catch-error-value-code-key.md | 11 - .../typed-expression-envelope-dialect.md | 63 - ...werable-target-refusal-opens-with-prose.md | 18 - ...ed-comparand-prescription-position-safe.md | 18 - .../validation-message-locale-negotiation.md | 15 - ...-shape-detail-prefers-unrecognized-keys.md | 13 - .../verify-reads-package-owned-collections.md | 37 - .../widget-measures-missing-every-family.md | 32 - .../zh-cn-dashboard-gap-source-parity.md | 22 - .../zh-cn-metadata-forms-source-parity-21.md | 39 - content/docs/deployment/self-hosting.mdx | 8 +- content/docs/upgrading.mdx | 2 +- docker/README.md | 8 +- examples/app-crm/CHANGELOG.md | 84 + examples/app-crm/package.json | 2 +- examples/app-multi-package/CHANGELOG.md | 71 + examples/app-multi-package/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 99 + examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 116 + examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 93 + examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 60 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 81 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 81 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 81 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 700 +++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 82 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 154 ++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 99 + packages/cloud-connection/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 78 + .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 78 + .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 78 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 78 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 44 + packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 400 +++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 2 + packages/create-objectstack/package.json | 2 +- packages/drivers/driver-memory/CHANGELOG.md | 198 ++ packages/drivers/driver-memory/package.json | 2 +- packages/drivers/driver-mongodb/CHANGELOG.md | 134 + packages/drivers/driver-mongodb/package.json | 2 +- packages/drivers/driver-sql/CHANGELOG.md | 141 + packages/drivers/driver-sql/package.json | 2 +- .../drivers/driver-sqlite-wasm/CHANGELOG.md | 84 + .../drivers/driver-sqlite-wasm/package.json | 2 +- packages/drivers/driver-turso/CHANGELOG.md | 139 + packages/drivers/driver-turso/package.json | 2 +- packages/formula/CHANGELOG.md | 87 + packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 456 +++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 94 + packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 71 + packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 6 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 307 +++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 323 +++ packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 568 ++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 71 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 300 ++ packages/platform-objects/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 71 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 79 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 79 + .../plugins/knowledge-ragflow/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 127 + .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 104 + packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 355 +++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 193 ++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 108 + packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 128 + .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 36 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-reports/CHANGELOG.md | 88 + packages/plugins/plugin-reports/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 166 ++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 168 ++ packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 97 + packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 146 + packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 71 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 14 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 322 +++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 609 ++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 2 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 215 ++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 430 +++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 79 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 72 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 78 + .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 158 ++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 91 + packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 95 + packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 78 + .../services/service-knowledge/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 91 + .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 79 + .../services/service-package/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 88 + packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 88 + .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 172 ++ .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 85 + packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 143 + .../services/service-storage/package.json | 2 +- packages/spec/CHANGELOG.md | 2442 +++++++++++++++++ packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 78 + packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 129 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 78 + .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 94 + packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 204 ++ packages/verify/package.json | 2 +- 387 files changed, 13654 insertions(+), 6919 deletions(-) delete mode 100644 .changeset/advisory-boot-path-aggregation.md delete mode 100644 .changeset/analytics-icontains-per-dialect-fold.md delete mode 100644 .changeset/analytics-measure-result-type-string-family.md delete mode 100644 .changeset/analytics-measure-result-type-temporal.md delete mode 100644 .changeset/analytics-preview-min-max-operand-type.md delete mode 100644 .changeset/analytics-text-family-case-exact-per-dialect.md delete mode 100644 .changeset/analytics-unknown-dialect-icontains-portable-fold.md delete mode 100644 .changeset/analytics-where-names-the-route-hop.md delete mode 100644 .changeset/api-duration-keys-unit-in-key-name.md delete mode 100644 .changeset/app-plugin-flat-bundle-seed-double-collect.md delete mode 100644 .changeset/approval-recall-docstring-override-scope.md delete mode 100644 .changeset/approvals-bu-member-org-screen.md delete mode 100644 .changeset/approvals-continue-restored-suspension.md delete mode 100644 .changeset/artifact-packages-collection-reads.md delete mode 100644 .changeset/assembled-package-body-plugins-envelope.md delete mode 100644 .changeset/assignment-value-cel-envelope-executor.md delete mode 100644 .changeset/attest-fresh-datastore-remedy-register.md delete mode 100644 .changeset/audit-router-keyed-identity-and-listnames-parity.md delete mode 100644 .changeset/auth-catchall-owned-404-not-yielded.md delete mode 100644 .changeset/auth-domain-claim-segment-boundary.md delete mode 100644 .changeset/automation-completed-run-history-throw.md delete mode 100644 .changeset/automation-failed-save-reseats-suspension.md delete mode 100644 .changeset/batch-row-unique-violation-metadata-protocol.md delete mode 100644 .changeset/blueprint-strict-mirror-value-parity.md delete mode 100644 .changeset/boot-sign-in-report-remedy-text.md delete mode 100644 .changeset/bulk-event-batch-organization.md delete mode 100644 .changeset/cast-blob-compile-option-claim.md delete mode 100644 .changeset/chart-config-missing-overreach.md delete mode 100644 .changeset/chart-empty-selection-rules.md delete mode 100644 .changeset/chart-field-unknown-refused-binding-tier.md delete mode 100644 .changeset/chart-measure-unknown-presentation-positions.md delete mode 100644 .changeset/claim-capability-probe-before-mutate.md delete mode 100644 .changeset/cli-explain-dashboard-refresh-interval-seconds.md delete mode 100644 .changeset/cli-hook-body-subpath-export.md delete mode 100644 .changeset/cli-lint-strict-warnings-fail.md delete mode 100644 .changeset/cli-migration-audit-stamp-nullability-and-default.md delete mode 100644 .changeset/cli-package-publish-help-local-dev-example.md delete mode 100644 .changeset/client-oauth-family-wire-shape-binding.md delete mode 100644 .changeset/client-readme-analytics-automation-payload-reads.md delete mode 100644 .changeset/client-readme-retired-ai-methods.md delete mode 100644 .changeset/colorfield-derive-describe.md delete mode 100644 .changeset/compliance-families-retired.md delete mode 100644 .changeset/compose-merge-refuses-object-collections.md delete mode 100644 .changeset/config-miss-refusal-to-stderr.md delete mode 100644 .changeset/connector-error-mapping-retired.md delete mode 100644 .changeset/console-a472b07167a3.md delete mode 100644 .changeset/contained-failure-visibility.md delete mode 100644 .changeset/core-private-keys-pin-extension-boundary.md delete mode 100644 .changeset/core-time-zone-domain-repoint.md delete mode 100644 .changeset/cron-dialect-row-names-croner.md delete mode 100644 .changeset/dashboard-gap-author-vocabulary.md delete mode 100644 .changeset/data-driver-aggregate-declared.md delete mode 100644 .changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md delete mode 100644 .changeset/datasource-admin-tenancy-posture.md delete mode 100644 .changeset/decision-predicate-envelope-refused.md delete mode 100644 .changeset/delete-data-request-schema-provenance.md delete mode 100644 .changeset/diagnostics-untyped-sweep-organization-forwarding.md delete mode 100644 .changeset/diff-usage-error-to-stderr.md delete mode 100644 .changeset/discovery-subscribable-channel-definition.md delete mode 100644 .changeset/dispatcher-scope-strip-environments-prefix.md delete mode 100644 .changeset/dist-freshness-declaration-stamp.md delete mode 100644 .changeset/driver-memory-auto-save-interval-ms.md delete mode 100644 .changeset/driver-memory-find-findone-create-honest-types.md delete mode 100644 .changeset/driver-memory-notcontains-non-string-value.md delete mode 100644 .changeset/driver-mongodb-test-tsc-program.md delete mode 100644 .changeset/driver-sql-text-operator-non-text-column.md delete mode 100644 .changeset/driver-sql-update-declared-null.md delete mode 100644 .changeset/driver-turso-config-timeout-ms.md delete mode 100644 .changeset/driver-turso-remote-text-operator-non-text-column.md delete mode 100644 .changeset/driver-turso-update-declared-null.md delete mode 100644 .changeset/duplicate-record-error-developer-message-wire-spelling.md delete mode 100644 .changeset/duplicate-source-must-be-a-base.md delete mode 100644 .changeset/duration-unit-in-key-name.md delete mode 100644 .changeset/email-template-shared-read-decoration-strip.md delete mode 100644 .changeset/engine-refusals-stamp-httpstatus.md delete mode 100644 .changeset/environment-artifact-checksum-coverage-describe.md delete mode 100644 .changeset/epoch-instant-and-external-vocabulary-exemptions.md delete mode 100644 .changeset/es-ja-dashboard-gap-source-parity.md delete mode 100644 .changeset/evaluated-expression-slot-requires-source.md delete mode 100644 .changeset/expression-source-non-string-refused.md delete mode 100644 .changeset/field-value-domain-write-path.md delete mode 100644 .changeset/filter-text-non-string-stored-value.md delete mode 100644 .changeset/filter-text-operator-declared-type-door.md delete mode 100644 .changeset/find-afterfind-array-guard.md delete mode 100644 .changeset/flow-action-record-load-signal.md delete mode 100644 .changeset/flow-edge-id-uniqueness.md delete mode 100644 .changeset/flow-end-node-refused-outcome.md delete mode 100644 .changeset/flow-node-id-uniqueness.md delete mode 100644 .changeset/flow-record-decoupled-from-batch-payload.md delete mode 100644 .changeset/gantt-tree-config-close-passthrough.md delete mode 100644 .changeset/generated-migration-audit-stamp-timestamptz.md delete mode 100644 .changeset/generated-migration-id-column-shape.md delete mode 100644 .changeset/great-clouds-repair.md delete mode 100644 .changeset/history-cleanup-failing-run-is-not-silent.md delete mode 100644 .changeset/history-cleanup-utc-retention-cutoff.md delete mode 100644 .changeset/hono-auth-mount-owned-404-not-yielded.md delete mode 100644 .changeset/hono-me-localization-user-locale.md delete mode 100644 .changeset/host-importer-aliased-dual-publish-entry.md delete mode 100644 .changeset/i18n-coverage-inline-locale-map-authored.md delete mode 100644 .changeset/i18n-declared-fallback-chain-rest.md delete mode 100644 .changeset/i18n-declared-fallback-chain-service.md delete mode 100644 .changeset/i18n-declared-fallback-chain-spec.md delete mode 100644 .changeset/i18n-extract-key-count-describes-emitted-bytes.md delete mode 100644 .changeset/i18n-extract-metadata-forms-flag-independence.md delete mode 100644 .changeset/i18n-walk-one-key-one-demand.md delete mode 100644 .changeset/import-row-unique-violation-rest.md delete mode 100644 .changeset/inert-deadline-keys-retired.md delete mode 100644 .changeset/init-generate-emit-service-object-annotation.md delete mode 100644 .changeset/injected-system-column-labels-localised.md delete mode 100644 .changeset/install-local-admission-tenancy-posture.md delete mode 100644 .changeset/job-service-replay-force.md delete mode 100644 .changeset/kernel-duration-keys-unit-in-key-name.md delete mode 100644 .changeset/layer0-verdict-on-operation.md delete mode 100644 .changeset/layered-read-org-gate-after-fold.md delete mode 100644 .changeset/lint-eval-generator-load-envelope.md delete mode 100644 .changeset/lint-eval-throwing-generator-unscorable.md delete mode 100644 .changeset/lint-eval-unscorable-stack-json-face.md delete mode 100644 .changeset/lint-generator-requires-eval.md delete mode 100644 .changeset/lint-non-record-collection-entry.md delete mode 100644 .changeset/lint-non-record-objects-readers.md delete mode 100644 .changeset/lint-readonly-create-scan-gap.md delete mode 100644 .changeset/list-view-grouping-server-side-contract.md delete mode 100644 .changeset/lucky-doors-tickle.md delete mode 100644 .changeset/lucky-poems-invite.md delete mode 100644 .changeset/magic-link-reads-recipient-locale.md delete mode 100644 .changeset/map-node-progress-state-lifetime.md delete mode 100644 .changeset/mcp-stdio-tenancy-posture.md delete mode 100644 .changeset/membership-ended-session-revoke.md delete mode 100644 .changeset/memory-analytics-date-range-timezone.md delete mode 100644 .changeset/memory-analytics-date-range-utc-window.md delete mode 100644 .changeset/memory-i18n-declared-fallback-locale.md delete mode 100644 .changeset/metadata-ambiguous-stem-refused.md delete mode 100644 .changeset/metadata-database-loader-ttl-ms.md delete mode 100644 .changeset/metadata-view-container-leaf-subpath.md delete mode 100644 .changeset/migrate-meta-chain-line-protocol-label.md delete mode 100644 .changeset/notification-event-migration-ledger-claims.md delete mode 100644 .changeset/notification-event-migration-run-receipt.md delete mode 100644 .changeset/object-door-searchable-listview-refusal.md delete mode 100644 .changeset/object-graph-null-entry-guard.md delete mode 100644 .changeset/objectql-boot-loop-refuses-divergent-view-name.md delete mode 100644 .changeset/objectql-hook-timeout-ms.md delete mode 100644 .changeset/objectql-system-write-organization-recognizer.md delete mode 100644 .changeset/olive-spiders-refuse.md delete mode 100644 .changeset/org-hierarchy-timezone-columns.md delete mode 100644 .changeset/os-create-emits-an-installable-project.md delete mode 100644 .changeset/plain-unique-index-duplicate-preflight.md delete mode 100644 .changeset/platform-object-tenancy-census-derived.md delete mode 100644 .changeset/platform-objects-dashboard-refresh-interval-seconds.md delete mode 100644 .changeset/plugin-auth-find-envelope-limbs.md delete mode 100644 .changeset/plugin-describe-ui-type-spelling.md delete mode 100644 .changeset/plugin-dev-i18n-detect-packages-reader.md delete mode 100644 .changeset/plugin-security-default-set-answer-not-container.md delete mode 100644 .changeset/plugin-security-scanner-ledger-entry.md delete mode 100644 .changeset/plugin-security-scanner-retired.md delete mode 100644 .changeset/plugin-sharing-field-recipient.md delete mode 100644 .changeset/preview-column-enrichment.md delete mode 100644 .changeset/provenance-stamp-per-row-dispatch.md delete mode 100644 .changeset/public-sharing-enabled-canonical-predicate.md delete mode 100644 .changeset/public-sharing-enabled-standing-policy-tsdoc.md delete mode 100644 .changeset/published-cli-stderr-nonblocking-guard.md delete mode 100644 .changeset/quiet-pans-repair.md delete mode 100644 .changeset/record-picker-filter-rule-array.md delete mode 100644 .changeset/reference-page-block-tag-payload-render.md delete mode 100644 .changeset/references-door-organization-forwarding.md delete mode 100644 .changeset/references-door-refusal-envelope-converged.md delete mode 100644 .changeset/registry-conflict-code-constants.md delete mode 100644 .changeset/remote-loader-list-nameless-guard.md delete mode 100644 .changeset/report-chart-axis-own-selection.md delete mode 100644 .changeset/rest-api-config-consumes-parse.md delete mode 100644 .changeset/rest-data-doors-compiled-against-protocol.md delete mode 100644 .changeset/rest-generic-passthrough-object-key.md delete mode 100644 .changeset/rest-translate-options-default-locale.md delete mode 100644 .changeset/runtime-declarative-row-update-executor.md delete mode 100644 .changeset/runtime-gate-stored-metadata-universe.md delete mode 100644 .changeset/runtime-job-timeout-ms.md delete mode 100644 .changeset/runtime-state-file-project-key.md delete mode 100644 .changeset/runtime-tenancy-posture-failure-discrimination.md delete mode 100644 .changeset/sandbox-writeback-entry-snapshot-normalised.md delete mode 100644 .changeset/scaffold-emission-policy-one-definition.md delete mode 100644 .changeset/schema-drift-single-value-json-column.md delete mode 100644 .changeset/scope-less-booted-row-attribution.md delete mode 100644 .changeset/score-metadata-lint-crash-visible.md delete mode 100644 .changeset/screen-flow-headless-satisfaction.md delete mode 100644 .changeset/seed-apply-read-back-decorations.md delete mode 100644 .changeset/seed-read-drops-dead-org-rung.md delete mode 100644 .changeset/serve-unlinked-database-file-watch.md delete mode 100644 .changeset/service-analytics-text-operator-non-text-column.md delete mode 100644 .changeset/service-datasource-turso-timeout-ms-reader.md delete mode 100644 .changeset/service-job-timeout-ms.md delete mode 100644 .changeset/service-storage-test-tsc-program.md delete mode 100644 .changeset/session-payload-positions-security-axis.md delete mode 100644 .changeset/session-unbacked-org-claim-dropped.md delete mode 100644 .changeset/session-user-language-retired.md delete mode 100644 .changeset/settings-admission-tenancy-posture.md delete mode 100644 .changeset/settings-door-value-domain-shared-predicate.md delete mode 100644 .changeset/share-link-admission-tenancy-posture.md delete mode 100644 .changeset/sharing-rule-evaluation-result-grants-refused.md delete mode 100644 .changeset/signup-existing-address-explicit-refusal.md delete mode 100644 .changeset/single-kernel-tenancy-posture-provider.md delete mode 100644 .changeset/spec-compose-key-dispositions-export.md delete mode 100644 .changeset/spec-i18n-default-locale-authored-label.md delete mode 100644 .changeset/spec-notification-event-migration-id.md delete mode 100644 .changeset/stack-cross-reference-refusal-envelope.md delete mode 100644 .changeset/storage-file-read-tenancy-posture.md delete mode 100644 .changeset/strand-verdict-survives-bookkeeping-throw.md delete mode 100644 .changeset/stranded-run-status-stamp.md delete mode 100644 .changeset/structural-condition-shape-refused.md delete mode 100644 .changeset/studio-object-field-ref-refusal.md delete mode 100644 .changeset/subflow-bubble-stranded-parent.md delete mode 100644 .changeset/summary-backfill-recompute-undefined-on-empty.md delete mode 100644 .changeset/suspended-run-cache-consumed-elsewhere.md delete mode 100644 .changeset/sys-email-error-description-widen.md delete mode 100644 .changeset/sys-email-highlight-fields-to-addresses.md delete mode 100644 .changeset/system-duration-keys-unit-in-key-name.md delete mode 100644 .changeset/tidy-cups-smile.md delete mode 100644 .changeset/today-offset-one-calendar.md delete mode 100644 .changeset/translation-actions-convention-docblock-keys.md delete mode 100644 .changeset/translation-liveness-group-boundary.md delete mode 100644 .changeset/tree-reference-self-only.md delete mode 100644 .changeset/truthful-stranded-decision-envelope.md delete mode 100644 .changeset/try-catch-error-value-code-key.md delete mode 100644 .changeset/typed-expression-envelope-dialect.md delete mode 100644 .changeset/unanswerable-target-refusal-opens-with-prose.md delete mode 100644 .changeset/undefined-comparand-prescription-position-safe.md delete mode 100644 .changeset/validation-message-locale-negotiation.md delete mode 100644 .changeset/value-shape-detail-prefers-unrecognized-keys.md delete mode 100644 .changeset/verify-reads-package-owned-collections.md delete mode 100644 .changeset/widget-measures-missing-every-family.md delete mode 100644 .changeset/zh-cn-dashboard-gap-source-parity.md delete mode 100644 .changeset/zh-cn-metadata-forms-source-parity-21.md diff --git a/.changeset/advisory-boot-path-aggregation.md b/.changeset/advisory-boot-path-aggregation.md deleted file mode 100644 index 682e37fdeb..0000000000 --- a/.changeset/advisory-boot-path-aggregation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": minor ---- - -Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. - -A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. - -Two changes, and neither moves what a rule evaluates to: - -- **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. -- **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. - -`error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. diff --git a/.changeset/analytics-icontains-per-dialect-fold.md b/.changeset/analytics-icontains-per-dialect-fold.md deleted file mode 100644 index 0d72acfa06..0000000000 --- a/.changeset/analytics-icontains-per-dialect-fold.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -Analytics `$icontains` no longer compiles a `translate()` call on the `sqlite` and `mysql` dialects. On **SQLite** that function does not exist and the statement failed to parse — measured on the engine, not inferred. On **MySQL** the same construct was emitted and its arm is repaired the same way, but nothing was ever executed there: the MySQL arm is asserted as emitted TEXT only, on this face and on `driver-sql`'s alike, so no MySQL parse failure is claimed as measured. - -`$icontains` folds ASCII case on both sides of the comparison (#4706 Q1 = A). All three of this package's SQL compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo of that statement — spelled that fold as `translate(col, 'ABC…', 'abc…')` on all four dialect values a compiler can see: `sqlite`, `mysql`, `postgres` and `unknown`, onto which `normalizeSqlDialect` maps everything else, an unset hook and `'oracle'` included. `translate()` is PostgreSQL/Oracle; SQLite has none. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate` — so this was not a filter that returned the wrong rows, it was a statement the engine refused. On a SQLite datasource, an analytics `where` carrying `$icontains` and an **RLS read scope** carrying it were both unusable. - -The fold is now chosen per dialect, on the same construct table the case-exact text family already used, reached through one `fold` flag: - -- **SQLite** — `lower(col) GLOB lower(?)`. SQLite's `lower()` is ASCII-only (measured: `lower('CAFÉ')` is `cafÉ`), so this is the ruled fold rather than an approximation of it, and it runs. -- **PostgreSQL** and the `unknown` residue — `translate()`, byte-for-byte what those two arms emitted before. Measured set for that word: this package's own suite pins six cells verbatim — `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {dialect unset, 'postgres'}` for `{name: {$icontains: 'acme'}}`, full emitted SQL and the exact bound params — and the round-1 contract review widened it to **2,721 cells** (2,720 = `{undefined, 'postgres', 'unknown', 'oracle'} × 5 compiler paths × 8 filter shapes × 17 comparands`, plus the bare `{dialect: undefined}` cell), emitted at the merge-base blobs (all five hash-verified) and again at this head: **0 changed cells, 0 error cells**. Outside that set nothing is claimed — no PostgreSQL server was contacted, and on `sqlite` and `mysql` the bytes deliberately changed (340 of 680 cells each, all inside the four `$icontains` shapes). -- **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator; the review measured the two faces byte-equal on 60 of 60 MySQL cells. Asserted as text only — no MySQL server is provisionable in the container that wrote this, so that cell is a declared skip, not a claimed pass. - -⚠️ Carve-out, stated because it is the surviving half of the defect and not an aside: an `unknown` dialect that is really SQLite is **not** fixed by this change. The residue is reached by four constructions the round-1 contract review drove rather than reasoned — a `SqlDriver` given a **class** client or an unrecognised spelling (`'libsql'`), a host hook answering knex's own `'sqlite3'`, a directly-constructed public `AnalyticsService` with the optional `sqlDialect` omitted, and a `data` service without `getDriverForObject`. For each of them `translate()` still reaches the engine and still fails to parse, on the `where` path, the read scope and the echo alike. No in-repo SQLite driver lands there — `SqliteWasmDriver` and `TursoDriver` both answer `"sqlite"`, measured — so this is an embedder-composition population, not a shipped-driver one. Tracked as #16028. - -`$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect the compilers accept — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. Measured set for that word: 510 cells (six dialect names — the four values above plus `'oracle'` and an unset hook, which both normalize to `unknown` — × 5 compiler paths × 17 comparands), 0 of them identical between the two families and no `$contains` cell carrying a fold. - -⚠️ One deliberate divergence from `driver-sql`, recorded here rather than only in this package's source: `driver-sql`'s own `unknown` arm folds with `LOWER()`, this one keeps `translate()`. Each face keeps the residue it already had, and adopting `LOWER()` here would silently restore on PostgreSQL the Unicode fold #4706 Q1 = A rules out. The pointer exists on this side only; `driver-sql` carries no cross-reference back. diff --git a/.changeset/analytics-measure-result-type-string-family.md b/.changeset/analytics-measure-result-type-string-family.md deleted file mode 100644 index 95c9311bd3..0000000000 --- a/.changeset/analytics-measure-result-type-string-family.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098) - -The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed. - -What changed: - -- **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape. -- **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`. -- **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift. - -Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch). - -Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration: - -- **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made. -- **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite. -- **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column. -- **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional. -- **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined. - -Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`. diff --git a/.changeset/analytics-measure-result-type-temporal.md b/.changeset/analytics-measure-result-type-temporal.md deleted file mode 100644 index 8f3511e6f9..0000000000 --- a/.changeset/analytics-measure-result-type-temporal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": patch ---- - -A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) - -`POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource): - -```json -{"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}], - "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]} -``` - -`min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch. - -What changed: - -- **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies. -- **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached. -- **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path. -- **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it. - -`AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string. diff --git a/.changeset/analytics-preview-min-max-operand-type.md b/.changeset/analytics-preview-min-max-operand-type.md deleted file mode 100644 index bcf28144a4..0000000000 --- a/.changeset/analytics-preview-min-max-operand-type.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type - -`POST /api/v1/analytics/dataset/query` has two producers of one response: the engine, and — when the request renders the as-if-published world over a pending seed draft (ADR-0037 P3) — `evaluateAnalyticsQueryOverRows`. The second one coerced every aggregate operand with `Number()` and dropped the non-finite ones, so a `min` / `max` over a non-numeric field answered `0`. Measured on one dataset and one row set, with two services differing only in whether a pending seed draft exists: - -``` -live {"category":"travel","latest_spend":"2026-05-12"} -preview {"category":"travel","latest_spend":0} -``` - -That is not a mislabelled column: it is a different, wrong answer to the same query, with no refusal and no warning, on the path an author is looking at *while* authoring the dataset. - -What changed, per member of the closed `AggregationFunction` vocabulary: - -- **`min` / `max` return the winning operand in its own type.** Ordering goes through this file's shared `compare` — so an ISO date orders as a date, a BSON `Date` orders as its instant against wire text, and text orders the way `MIN(text_col)` does on a SQL face — with a numeric arm so a numeric column written as text (`'800'`) still orders numerically. `cross-object-rebucket.ts` settled the identical question for the recombination path: the value these two pick is a value OF the column, so it must come back in the shape the row carried. -- **A group whose operand is null throughout answers `null`, not `0`** — `emptyGroupValueFor` (`@objectstack/spec/data`) rules `min` / `max` over nothing unanswerable, and `0` reads as a measurement nobody made. -- **`count_distinct` answers a cardinality again.** Its arm was spelled `countDistinct`, a word no producer mints (`dataset-compiler` copies the spec's `count_distinct` through), so it was unreachable and the measure fell to the numeric default — answering a row count under the author's `count_distinct` name (measured: `3` where the live path says `2`). -- **`count` stays a row count and `sum` / `avg` stay arithmetic.** Counting dates is still counting. -- **`sum` / `avg` over a TEMPORAL operand is deliberately unchanged.** There is no defined answer — the SQL faces do not agree on one either — and refusing an incoherent aggregate/field-type pair is an open decision, not this fix's to invent. -- **A dimension column is typed from the cube dimension**, the same expression both live producers use (`d?.type || 'string'`), so a `date` dataset dimension is `time` on the preview path as it already was on the live one. A MEASURE column keeps the `number` every producer mints; correcting that is the ADR-0021 descriptor pass's one rule, not a second copy here. - -Derived measures are untouched: `computeDerived` still coerces with `Number()` and answers `null` for a non-finite operand — but a derived ratio over a temporal `min` / `max` now sees a date instead of the spurious `0`, so it answers `null` on the preview path exactly as it already did on the live one. diff --git a/.changeset/analytics-text-family-case-exact-per-dialect.md b/.changeset/analytics-text-family-case-exact-per-dialect.md deleted file mode 100644 index 54df4f9997..0000000000 --- a/.changeset/analytics-text-family-case-exact-per-dialect.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/driver-sql": minor ---- - -The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) - -`$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. - -On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. - -What changed: - -- **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. -- **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. -- **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. - -`$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. diff --git a/.changeset/analytics-unknown-dialect-icontains-portable-fold.md b/.changeset/analytics-unknown-dialect-icontains-portable-fold.md deleted file mode 100644 index 3429078ddc..0000000000 --- a/.changeset/analytics-unknown-dialect-icontains-portable-fold.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -Analytics `$icontains` no longer compiles a `translate()` call on the `unknown` dialect arm, so a datasource whose dialect nothing answered — which includes SQLite — gets a statement its engine can parse. **Graded `patch`:** no exported type, signature or option changes; the package's own contract for the operator (#4706 Q1 = A, an ASCII-only fold on both sides) is unchanged, and this repairs an arm that could not run rather than adding or retiring behaviour. What moves is emitted SQL text on one arm, measured and enumerated below. - -`normalizeSqlDialect` maps **everything it cannot name** onto `unknown`: an unset `sqlDialect` hook, `'oracle'`, `'libsql'`, a `SqlDriver` handed a knex Client **class** rather than a spelling. #15780 left that arm folding with `translate()` and recorded it as "never broken", which was true of the dialects the arm was *pictured* as — mssql and oracle, which have `translate()` — and false of the ones actually routed there. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`, so on all three of this package's compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo — the statement failed to **parse**. It reached the client as a 500, not an ADR-0112 refusal. One of the four constructions that land there is a directly-constructed public `AnalyticsService` with its **optional** `sqlDialect` omitted: leaving out an optional field turned a documented operator into a 500. - -The `unknown` arm now folds with one nested `REPLACE` per ASCII letter — the chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two arms cannot fold different alphabets. `REPLACE` is the one string function every SQL dialect has, and the domain is the same 26-letter constant, so the fold is ASCII-only **by construction**: - -- **PostgreSQL / Oracle-like** — same result set as `translate()`. The chain equals the simultaneous `A`-`Z` map because no step can feed a later one: every replacement writes a lower-case letter and every later step matches an upper-case one. Measured on the engine over **every ASCII code point** plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly. -- **SQLite-like** — it runs. Executed over the shared `FILTER_TEXT_CASES` `$icontains` rows through all three compilers on sql.js: the same row sets the `sqlite` arm is required to answer, including the `CAFÉ`/`café` pair that separates an ASCII fold from a Unicode one. -- ⛔ **Not `LOWER()`**, which is what `driver-sql`'s own `unknown` arm folds with. `LOWER()` follows the collation, so adopting it would trade this parse failure for **silently wrong rows** on PostgreSQL — the Unicode fold #4706 Q1 = A rules out. ⚠️ Measuring `LOWER()` in this container proves nothing about that: SQLite's `lower()` is ASCII-only and passes the same fixture, which is exactly the trap of letting a green SQLite reading stand in for a PostgreSQL one. No PostgreSQL server was contacted. - -**Which cells moved.** The emitted SQL and bound params of `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands` = **1,785 cells**, generated at this head and again with the emitter reverted to its merge-base blob (both legs hash-verified on disk and rebuilt, the marker's presence and absence checked in `dist/`): **204 moved, 1,581 byte-identical, 0 error cells either side.** Every moved cell is `$icontains` on one of the four dialect inputs that normalize to `unknown` (51 each = 17 comparands × 3 compilers). **0 of the 204 changed their bound params** — only the fold's spelling moved, never the escaping or the `ESCAPE` binding. Nothing moved on `postgres`, `sqlite` or `mysql`, and no case-exact operator moved on any dialect input. - -⚠️ **The cost, stated rather than left to be found:** the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run. - -⚠️ **The residue that remains**, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is `LIKE`, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the `'mariadb'` spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the **same** residue this arm's case-exact neighbour already carries and names — not a new one — and on those engines `translate()` did not run at all, so nothing that answered correctly before stops answering. - -`SqliteWasmDriver.dialectName` gains a direct pin. It answers `"sqlite"` only through an `isSqlite` override (the base class string-matches `config.client`, and this transport passes a class), that override had **0 direct test hits**, and it is the sole reason no in-repo SQLite driver reaches the arm above. The new pin includes the control: the base class answers `'unknown'` for that very config. diff --git a/.changeset/analytics-where-names-the-route-hop.md b/.changeset/analytics-where-names-the-route-hop.md deleted file mode 100644 index e20f8ebe8e..0000000000 --- a/.changeset/analytics-where-names-the-route-hop.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Documentation: the analytics `where` contract and the `element:number` D3 entry now name the hop an array filter is lowered at. - -Text only — no schema, accept-set, runtime or test behaviour changes. `AnalyticsQuerySchema.where` is still `FilterConditionSchema` and still refuses an array, which is the protocol working as `FilterArray`'s docblock (#5158 ruling C) declares it: a `FilterArray` is input-only authoring sugar, lowered to a `FilterCondition` at the single sink `parseFilterAST` (`@objectstack/spec/data`) the moment it arrives, and only the lowered `FilterCondition` travels any further. - -- `AnalyticsQuerySchema.where`'s `.describe()` gains one sentence pointing array authors at that lowering: an authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire, and this field admits only the lowered `FilterCondition`. It lands in the generated `content/docs/references/{api,data}/analytics.mdx` prop tables, which is where an author reads it. -- The `element-number-filter-rule-array` semantic migration entry recorded its runtime prerequisite one hop too late: "authored array → adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`". `lowerAnalyticsWhere` (`service-analytics`) is the in-process door (#5334) for callers reaching `analyticsService.query` directly. The wire's door is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` before any service code runs, so an un-lowered array is refused there. The entry's reason clause now names that route hop and the `parseFilterAST` lowering the adapter owes before the wire (#15828; the adapter-side fix is objectui#7752). - -The sibling entry `element-record-picker-filter-rule-array` was read for the same claim and does not make it — its measured path is `find()` / `convertQueryParams`, not the analytics wire — so it is unchanged. diff --git a/.changeset/api-duration-keys-unit-in-key-name.md b/.changeset/api-duration-keys-unit-in-key-name.md deleted file mode 100644 index cbea5238b1..0000000000 --- a/.changeset/api-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": patch ---- - -feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) - - - -**BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. -Shipped as `minor` under the repo's launch-window convention for breaking -changes; the hand-migration prescriptions are registered under protocol major -18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, never only in its `.describe()` prose, and grandfathers no -existing offender. Stack card 1/6 (#15676) landed the rule's two structural -exemptions; this card clears the `api/` directory against it. Measured with the -gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree -count falls **48 → 36**. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | -| `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | -| `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | -| `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | -| `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | -| `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | -| `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | -| `RouteDefinition.timeout` | `timeoutMs` | milliseconds | -| `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | -| `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | -| `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | -| `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | - -**Every value is unchanged** — only key names move. Every old spelling is a -`retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type -`never`) and fails the parse with the rename prescription rather than a bare -unrecognized-key error. - -## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch - -Ruling B put this key explicitly in scope with its own BREAKING note: the -runtime-emitted measurements are read by humans and agents even though nobody -authors them. A consumer meets two retry-after values on one 429 — this -ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` -header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. -Spelled identically they read as one value in two places. - -**The HTTP `Retry-After` response header is a separate, unchanged surface.** Its -name is fixed outside this repo and nothing here touches it. Do not "fix" the -header to match the envelope, and do not read a surviving `retry-after` in -transport code as leftover work. - -## Dispositions — one D2 conversion, five semantic entries - -Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only -one of the twelve that gets an ADR-0087 D2 conversion** -(`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack -collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered -metadata kind stored as a row, so the conversion chain has a seam that sees it. -`os migrate meta --from 17` lists the mechanical edits. - -The other eleven are wire payloads and construction arguments — a device-flow -response body, an error envelope, REST-plugin route registration, a batch-loader -config, a router registration, WebSocket client/server configuration. None is -ever a stack collection member or a `sys_metadata` row, so no conversion seam -runs on them and each carries a **semantic** entry instead: this is the -disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these -very shapes, and what ruling B prescribes for a runtime-emitted key. - -## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror - -Attributed to RFC 8628 by the campaign card; the attribution fails against the -schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as -a set — `code` is not `device_code`, `verificationUrl` is not -`verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a -different type, an ISO-8601 instant where the RFC carries a relative lifetime). -A schema that already renames every RFC field it carries into house style cannot -claim the standard fixes the one name it left bare. Renamed rather than marked -deliberately: a wrongly marked key is exempted permanently and silently, while a -wrongly renamed one is visible. - -## Readers moved in the same PR, at the same magnitude - -`@objectstack/runtime`'s policy chain (`computeCacheControl` now reads -`endpoint.cacheTtlSeconds`), the publish gate's issue path -(`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase -example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` -tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is -retargeted onto the live key — an alias must point at a key the schema really -accepts, and `cacheTtl` now accepts nothing. diff --git a/.changeset/app-plugin-flat-bundle-seed-double-collect.md b/.changeset/app-plugin-flat-bundle-seed-double-collect.md deleted file mode 100644 index f64edf62b9..0000000000 --- a/.changeset/app-plugin-flat-bundle-seed-double-collect.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice - -`AppPlugin.start()` collects seed data from two locations — the top-level -`data` field, then the legacy `manifest.data` for backward compatibility. The -legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a -FLAT bundle — manifest fields written directly on the bundle rather than nested -under `manifest:`, a shape `AppPlugin` supports by design and this repo's own -tests construct — it re-read the very array the top-level read had just -contributed. Every dataset landed in the collection twice. - -`mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies -reached the shared `seed-datasets` registry, the inline boot seed, and every -later per-org replay. For an `upsert` dataset with an `externalId` the second -pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset -it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls -for one record. - -The legacy read now carries the same reference guard its sibling collector has -always carried: `loadTranslations()` performs the identical two-location read -and skips the legacy half when `manifest.translations` IS the array the top -level already contributed. That asymmetry between the two collectors was the -whole defect, so the repair is the sibling's guard rather than a third spelling -of the same idea. - -⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a -genuinely different array from its top-level `data` still contributes both, and -a bundle that nests its manifest is unaffected either way. Nothing is added to -or removed from any published surface. diff --git a/.changeset/approval-recall-docstring-override-scope.md b/.changeset/approval-recall-docstring-override-scope.md deleted file mode 100644 index 82dfc4d475..0000000000 --- a/.changeset/approval-recall-docstring-override-scope.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`IApprovalService.recall`'s contract prose names every actor who may recall, and scopes each one by status (#14670) - -**Documentation only — no key, no accepted value, no runtime behaviour moves.** The implementation has been correct since #12775; only the contract's description of it was stale. - -The docstring said *"Only the submitter (or a system context) may recall"*, then widened to `returned` requests in a second paragraph. Both halves were wrong, in opposite directions: - -- **The list was not exhaustive.** A #3424 override actor — a platform or tenant admin holding no approver slot — may recall a `pending` request. That is the in-product recovery path for an approval routed to an unstaffed position, and this same file already documented it 387 lines above the sentence denying it: the docblock on `ApprovalRequestRow.viewer.can_override` spells the override's levers as `(approve / reject / reassign / recall it)`. One file, two contradicting sentences about the same verb. -- **The ADR-0044 widening read as though it applied to that whole list.** It does not. The override and system arms are ANDed with `status === 'pending'` where they are computed, so neither reaches a `returned` request; an override actor is refused there exactly as any other non-submitter (#12775, maintainer ruling 2026-09-02). Abandoning a revision window is the submitter's alone. - -The rewrite makes **status** the axis instead of appending a caveat, so the second defect cannot come back on a re-read: each status carries its own admitted set, and the `returned` bullet says outright that the submitter is alone in it. - -`ApprovalRecallInput.actorId` carried the same stale sentence (*"Must be the request's submitter (or a system context)"*) and is corrected with it. Fixing only the method docstring would have left the contradiction alive on the very input type the corrected method takes. - -The two sibling docstrings sharing that phrasing are **correct and unchanged**: `ApprovalSendBackInput.actorId` and `ApprovalResubmitInput.actorId`. `isOverrideActor` is called from exactly five places in `plugin-approvals` — `decideNode`, `reassign`, `recall`, `attachViewers` and `visibleRequestIds` — and neither `sendBack` nor `resubmit` is among them, so no override actor reaches either. - -The published prose already described the corrected rule (`content/docs/automation/approvals.mdx`: an admin "may act on any `pending` request — approve, reject, reassign it to a real approver, or recall it"). This docstring was the one surface that had not kept up. diff --git a/.changeset/approvals-bu-member-org-screen.md b/.changeset/approvals-bu-member-org-screen.md deleted file mode 100644 index 61d6e389c1..0000000000 --- a/.changeset/approvals-bu-member-org-screen.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -Fix: a `department` approver on a seeded business unit no longer routes the approval to another organization's members. - -`ApprovalService.expandBusinessUnitUsers` screened the `sys_business_unit` rows with the null-inclusive tenant predicate (#3807 — a seeded unit carries no organization and is admitted on purpose) but read `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either. A seeded unit id exists identically in every tenant, so a `department:` approver on tenant A's request resolved the shared unit and then collected every tenant's membership rows hanging off it — approval authority over A's record, routed to B's users. The member read now carries a strict `organization_id` equality against the directory organization the approver resolves in: the same screen `plugin-sharing` applies to these rows, and the same posture this package already takes for `sys_team_member` and `sys_user_position`. - -The screen is strict rather than null-inclusive on purpose. `sys_business_unit_member.organization_id` is filled by REST/session writes but left NULL by seed replay and by elevated system-context writes (tracked in #14570), so a NULL on a membership row means unknown tenancy, not "platform-global", and routing fails closed on it. Declared cost: on a deployment whose membership rows (not merely its units) were seeded or system-written, a `department` approver on a request that carries an organization now expands to nobody — the slot falls to the `department:` literal, the existing `expanded to nobody` warning (#3807) names it, and `onEmptyApprovers` governs the request as for any unstaffed target. The repair is to stamp those membership rows. A request that carries no organization is unchanged, and so is every unit-level screen. diff --git a/.changeset/approvals-continue-restored-suspension.md b/.changeset/approvals-continue-restored-suspension.md deleted file mode 100644 index 424f4a63a8..0000000000 --- a/.changeset/approvals-continue-restored-suspension.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -A restored approval suspension can now be decided again, not only cancelled. - -`AutomationEngine.restoreConsumedSuspension` re-arms the pause of a run that stranded mid-resume and tells the operator to *re-issue the continuation*. For an `approval` suspension nobody could: every approvals door that stamps the resume marker — `decide`, `recall`, `sendBack`, `resubmit` — guards on a `pending` request, and the row is terminal, written by the very call that stranded the run; and the generic engine door refuses an `approval` pause outright, because that node declares `resumeAuthority: 'service'`. The only remaining verb was `cancelRun`, which discards the branch's downstream work — so the advertised repair produced a run that looked resumable and was not decidable. - -Measured against the real engine and the real decision door: the restored suspension lacks nothing. A `resumeAuthority`-marked resume walks the restored pause to completion. What was missing was an **issuer** on the approvals side, and that is what this adds. - -- **`ApprovalService.continueRestoredRun(requestId, options?)`** re-issues the continuation the recorded outcome already produced once, against a pause an operator has re-armed. It reports which outcome it replayed, which edge it walked, and whether the signal was replayed exactly or rebuilt (`source: 'journal' | 'reconstructed'`). -- **The failing door now journals the signal it was carrying** on the repairable exit — the engine's own `status: 'stranded'` discriminator, the one exit that journals a repair snapshot — under `__strandedContinuation` in the request's `node_config_json`, beside the `__decisionOutputs` side-channel that was already there. Best-effort: it is awaited but can never replace the `RESUME_FAILED` throw the decision's caller is owed. -- **The continuation is tied to this request's own pause, by three guards.** A boolean "is this run suspended" is not enough: a run outlives any one request, so a terminal row's continuation could be issued against whatever pause the run happened to be sitting on. It now requires that the request is still the newest on its run, that a pause exists (strictly — an unreadable store throws rather than reading as "not suspended"), and that the pause is parked **where this request's recorded outcome was issued from**. That node is signal-aware, not simply the row's own: `approve`, `reject`, `revise` and `recall` are all issued at the request's own approval node, but a `resubmit` is only ever issued from the revise window the request's `revise` edge leads to, so its pause is re-armed there while the row still records the approval node. Comparing against the row's own node refused exactly that case, and told the operator the pause was not this request's when it was. The node check is fail-closed in every direction, including an engine that cannot report where a run is parked and a revise window this service cannot derive from the flow definition. This needs no new automation-engine surface: `listSuspendedRunsDurable` is already public, and the approvals-side resume interface simply declares it. -- **Runs stranded before this shipped are served too**, and where the signal cannot be proved the verb **refuses instead of guessing**. A status is not the same thing as a continuation, and three of the four terminal statuses have more than one writer or issuer: `approved` is unambiguous; `rejected` has two writers, discriminated by the `revise` action row that only ADR-0044's revision-limit auto-rejection leaves behind; `returned` has one writer but **two** issuers, discriminated by the `resubmit` action row whose sole writer is `resubmit` — without it a stranded resubmit was rebuilt as a send-back and walked the wrong edge, proceeding only through the engine's unmatched-label fallback with the wrong output; and `recalled` has two writers across **three** behaviours, two of which issue no continuation at all, so it is **refused on the rebuild path** with a message naming what an operator can do instead. Journal-recoverable is a **measured, named set** rather than a blanket claim: `approve`, `reject`, `resubmit` and `recall` continuations replay end to end through the verb, and `reject` and `resubmit` do so on the rebuild path as well. Two shapes are refused by design and stay refused — a `rejected` row that also carries a `revise` action, and a `recalled` row with no journal. NOT covered by a pin, and so not claimed: the `approve` rebuild path. - -- **A journalled signal is checked against what the row's status can have issued, before it is replayed.** The journal records what the last FAILED resume was carrying, and nothing rewrites it when a later door moves the row on — so a signal can outlive the state that issued it. Measured, with no injected failure beyond the strand: a `resubmit` strands and journals `resubmit`; the submitter then recalls, a real `cancelRun` on an already-stranded run answers `false`, the row is marked `recalled` and the run stays parked; the restore re-arms the pause; and the stale `resubmit` was replayed, opening a fresh `pending` round on a request somebody deliberately withdrew. Every step an ordinary action answering ordinarily. A row is now replayable only for a continuation its own status can have issued — `approved`→`approve`, `rejected`→`reject`, `returned`→`revise` or `resubmit`, `recalled`→`recall`, and nothing at all for a status nobody has enumerated. ⛔ Clearing the journal after a successful replay does not close this and was measured not to: the offending replay is the FIRST replay of that journal, so a clear that fires afterwards can never run before the advance it would prevent. - -⛔ What this deliberately does not do, each pinned: it does not re-open or rewrite the request row — all four `pending` guards are untouched and no status, mirror field or audit row is written, so a decided request still cannot be decided again through the front door; it does not relax `resumeAuthority: 'service'`, since the resume still goes through the one call site that stamps the marker; and it does not change `ApprovalDecisionResult`, whose shape is the subject of an open ruling. It also grants no capability in-process code did not already have — `RESUME_AUTHORITY_SERVICE` is importable by any host — what it adds is the guarded form, and the guards are stated as what they actually check: that this request is still the newest on its run, that a pause exists at all, that it is parked where this outcome was issued from, and that the recorded signal is one the row's present status can have issued. ⛔ None of them checks that the pause was consumed and genuinely re-armed, and an earlier wording of this entry claimed one did: a `returned` row with a resubmit action row and a pause that was never consumed is admitted, with `restoreConsumedSuspension` itself answering *"already resumable — nothing to restore"*. That shape is benign — the recorded action is the submitter's own resubmit, so the step it walks was decided — but it is not what any guard tests. Like the engine verb it completes, it is an in-process operator repair: no REST route, and no entry in the spec `ApprovalService` contract. diff --git a/.changeset/artifact-packages-collection-reads.md b/.changeset/artifact-packages-collection-reads.md deleted file mode 100644 index e306662df5..0000000000 --- a/.changeset/artifact-packages-collection-reads.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a multi-package artifact's collections are read from `packages[]`, not only from the flattened top level - -A release artifact composed with `manifest: 'preserve'` carries every -definition twice — flattened at its top level, and again under -`packages[]` (ADR-0130 D4). Only two readers had ever learned the second -half: `ObjectQLPlugin`'s manifest service and the metadata artifact door. -Every other reader said `artifact.` and nothing else, so an -artifact that carried a collection under `packages[]` alone reached them -EMPTY — and nothing threw. The app booted clean having lost its -declarative actions, its scheduled jobs, its seed data, its object routing -or its default permission set. - -`resolveArtifactCollections` — new, and PACKAGE-PRIVATE to -`@objectstack/runtime` — is now the one way this package reads a top-level -collection out of an artifact in either shape. It takes the artifact's own -top-level value first and whole, then adds from each package body — in -`resolveArtifactPackageOrder`'s dependency order — the items the top level -did not already claim. A bundle that carries no `packages[]` is returned -unchanged, by identity: every single-package artifact and every -`defineStack()` config reads exactly as before. Nothing is added to any -package's published surface: `@objectstack/core` is untouched by this -change, and the new module is not named by -`packages/runtime/src/index.ts`. - -Where one collection key is spelled two ways inside one artifact — -`functions` is `z.union([z.record(…), z.array(…)])`, so two packages can -each be schema-valid and disagree — the read is REFUSED with an ADR-0112 -envelope (`MIXED_ARTIFACT_COLLECTION_SHAPE`, 422) rather than one spelling -being skipped. `composeStacks` already refuses the same mix at compose -time for the same reason. - -Taught to use it, in `@objectstack/runtime`: - -- `AppPlugin` — declared datasources and their auto-connect, the - `datasourceMapping` object routing, the objects handed to the connection - service and to the hot-reload seeder, scheduled jobs, seed datasets, - translation bundles, and the ADR-0057 security collections - (`positions` / `permissions` / `capabilities` / `sharingRules`). A job - handler's `ctx.bundle` is now the resolved view too, so - `ctx.bundle.objects` answers on a multi-package artifact. -- `collectBundleActions`, `collectBundleHooks` and - `collectBundleFunctionEntries` — including the object-EMBEDDED actions - that ride on `objects[]` and disappeared with it. -- `mergeRuntimeModule` — the declaration half. The sibling ESM module - re-supplies every callable regardless of shape, so `functions` was not - absent: a function declared `effect: 'writes'` simply came back as a bare - callable and defaulted to `'pure'`. It registered, it ran, and its writes - were counted as none. -- `createStandaloneStack`'s surfaced `requires` / `objects` / - `permissions` / `positions`, which drive the CLI's tier resolution, its - engine and storage-driver auto-registration, and the ADR-0056 D7 default - permission set. -- `resolve-project-database`'s project-database tier, which opens the - artifact itself and runs before any stack exists (`os dev`, `os start`, - `os db clean`). Without this a multi-package project silently fell - through to the unified default database instead of the datasource it - declared. - -Nothing about what the platform EMITS changes: `composeStacks` and the -artifact format are untouched, and the flattened top level is still -written. This is the reader half of the option-B program (#14512). diff --git a/.changeset/assembled-package-body-plugins-envelope.md b/.changeset/assembled-package-body-plugins-envelope.md deleted file mode 100644 index 985cb214fb..0000000000 --- a/.changeset/assembled-package-body-plugins-envelope.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219) - - - -**BREAKING** accept-set narrowing on `AssembledPackageBodySchema` — the body -under `packages[i].manifest` of a release artifact (ADR-0130 D4): a body that -carries `plugins` or `devPlugins` is now **refused** at the manifest's strict -close (`unrecognized_keys`, naming the key), where it used to parse. Shipped as -`minor` under the repo's launch-window convention for breaking changes; the -hand-migration prescription is registered under protocol major 18. Maintainer -ruling 2026-09-04 on #15219 (director decision batch #32, verbatim 「同意」): -option A for both keys. - -`plugins` and `devPlugins` were members of the assembled-body key set by the -same derivation every other collection uses (`COMPOSE_KEY_DISPOSITIONS` gives -both `concat`). They are the only members whose values are **runtime assembly -instructions** rather than serialisable metadata: `plugins` holds what a host -hands to `kernel.use()` — live plugin instances, manifests or package names — -and `devPlugins` is the `os dev` load list. Inside an artifact a package body -is inert JSON, so a plugin under `packages[i].manifest` could never be -constructed by a loader; every reader reads the top level. The classification -is corrected rather than special-cased: an artifact carries metadata, a host -assembles plugins. - -**What changes** (`packages/spec/src/stack.zod.ts`): - -- `plugins` / `devPlugins` are **envelope keys** — top level only, never inside - `packages[]`. `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` (`packages`, `plugins`, - `devPlugins`) is declared once and feeds both the `AssembledPackageBodyKey` - derivation and `assembledPackageBodyShape()`. -- Both keys stay `concat`: a live stack still concatenates its plugins to the - top level under `composeStacks`, and `manifest: 'preserve'` no longer folds - them into any package body. -- The two declarations on the stack schema are unchanged. - -**What does NOT change:** `os serve` / `os migrate` / `os dev` keep reading the -top level (now correct by construction); no CLI, core or runtime code moves. - -## FROM → TO - -```ts -// before — a package body inside an artifact could carry plugins nobody could load -{ packages: [{ manifest: { id: 'com.example.crm', /* … */ plugins: [{ name: 'plugin.x' }] } }] } - -// after — plugins live on the artifact envelope only; the body above is refused: -// packages.0.manifest: unrecognized_keys ['plugins'] -{ plugins: [new CrmPlugin()], packages: [{ manifest: { id: 'com.example.crm', /* … */ } }] } -``` - -**Migration.** Declare `plugins` / `devPlugins` at the stack top level and -delete them from every `packages[i].manifest`. An existing multi-package -artifact that carries `packages[i].manifest.plugins` (if `os build` ever wrote -one — not directly measured) is refused on load after this change and must be -rebuilt from source; a hand-written `packages[]` entry drops the keys. Stacks -that only ever declared the two keys at the top level parse byte-identically. diff --git a/.changeset/assignment-value-cel-envelope-executor.md b/.changeset/assignment-value-cel-envelope-executor.md deleted file mode 100644 index 34b69590b9..0000000000 --- a/.changeset/assignment-value-cel-envelope-executor.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention; the level also follows the 2026-09-04 bump ruling — -this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an -additive widening is at least `minor`). No ADR-0087 conversion: no authorable key -is renamed or retired, and the shape this refuses was never a shape any surface -offered. - -The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be -a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size` -…) is finally reachable from metadata — until now CEL was only ever asked for a -boolean. The spec half landed the contract (PR #15113); this is the half that -makes it do something. - -```yaml -# before: written into the variable verbatim, and rendered by `notify` as -# {"dialect":"cel","source":"joinNonEmpty(...)"} -# now: evaluated — digest is "Renewal due\nInvoice overdue" -assignments: - digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' } -``` - -- **Evaluated at run time.** The built-in `assignment` executor evaluates a - `value`-role envelope with the expression engine and assigns the result, in the - same CEL scope a flow predicate is evaluated in (one shared scope builder, so a - predicate and a value expression cannot disagree about what `rows` means). A - plain string keeps today's `{token}` interpolation, and every other literal is - still assigned as data. -- **Refused at three doors.** A malformed envelope now stops the flow registering - (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as - a located `error` finding naming the node and the author's own variable — - `config.assignments.digest` — both at `objectstack validate` and at the runtime - publish gate a Studio / REST / MCP flow write goes through - (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes: - ['flow']`). Malformed is a composition, not a fixed list: whatever - `AssignmentValueSchema` refuses in the envelope's shape — among them a missing, - empty or non-string `source`, a dialect other than `cel`, a non-object `meta` — - and then CEL that does not parse. All three doors derive that set from the same - two published validators, so none refuses a shape the executor would have run, - and a registered flow never faults for a shape those validators judge malformed. - Two shapes sit outside what either validator can judge — an `ast`-only envelope - and a whitespace-only `source` (it passes `min(1)` and reads as "not authored" - to the validator, while the CEL engine parses it untrimmed) — and those fault - loudly at run time rather than assigning a value. Both are pinned and tracked in - #15430. -- **Only the canonical map.** The ledger declares `assignment.assignments.*` and - nothing else, so the two legacy shapes the executor still normalizes — the - `assignments: [{ variable, value }]` array and the bare `{ : }` - config — keep every meaning they had, envelope-shaped values included. - `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the - array form: refusing it would break flows that register today, and that refusal - is a maintainer ruling rather than a lane's call (#15137 ask 3). - -**What changes silently, and how far it reaches.** A flow that today authors an -envelope-shaped object *as data* in the canonical `assignments` map now evaluates -it — no error on either side, a different value. The discriminator is the spec's -own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`, -in the declared map only. Data that names no `dialect`, names a non-string one, -nests the envelope one level down, or sits in either legacy shape is untouched -and byte-identical. The remaining overlap — a well-formed -`{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly -the spelling the ruling reinterprets; every near-miss the two validators can -judge now refuses loudly at registration instead of changing value in silence. diff --git a/.changeset/attest-fresh-datastore-remedy-register.md b/.changeset/attest-fresh-datastore-remedy-register.md deleted file mode 100644 index f76fc11ee5..0000000000 --- a/.changeset/attest-fresh-datastore-remedy-register.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -`attestFreshDatastore` looks its `os migrate` remedy up instead of defaulting it - -When a fresh datastore's own boot has already admitted a value that contradicts a -migration's contract, that id is not attested and the operator is told what closes -the gate on real evidence. The sentence used to be built from a two-way branch: the -file-references id got `files-to-references`, and **every other id** got -`value-shapes` by default. - -`CREATION_ATTESTED_MIGRATION_IDS` has three members. For the third — -`adr-0030-notification-event` — that default is a wrong prescription: `os migrate -value-shapes --apply` neither attests nor clears it, and there is no `os migrate -notification-event` sub-command to send an operator to at all (that cut-over is an -operator call with no self-check). - -The branch is now an explicit id-to-remedy register, total over the ids a -value-shape tally can contradict. The loop asks it rather than falling into an arm, -so an id with no value-shape contract is never-contradictable by that evidence and -is attested on the birth observation as before. A new member therefore inherits no -remedy: adding a third arm that happened to be right today would only have moved the -same defect onto the fourth member. - -No behaviour changes for the two ADR-0104 ids, which is where every reachable path -runs today: the shipped engine keys its admitted-violation tally from a closed -`'media' | 'value-shape'` union, so it cannot name a third id. diff --git a/.changeset/audit-router-keyed-identity-and-listnames-parity.md b/.changeset/audit-router-keyed-identity-and-listnames-parity.md deleted file mode 100644 index 2ccbfb14de..0000000000 --- a/.changeset/audit-router-keyed-identity-and-listnames-parity.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/objectql': minor ---- - -feat(metadata,objectql): a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router - -Two plural reads of one metadata plane could disagree with a by-name read of -that same plane, and the ADR-0110 D5 action-governance audit stood on the -disagreement — reporting `registered handler with NO declaration … REFUSED at -dispatch` about a route the router was resolving and dispatching in the same -boot. - -**`MetadataManager.listNames` gains the per-loader `try`/`catch` that -`loadMany` and `list()` have carried since #5108.** One loader fault used to -produce two different facts depending only on which plural read a caller -reached for: `loadMany` swallowed it and answered short, `listNames` threw. It -now degrades the same way, through the same `reportLoaderReadFailure` / -`reportLoaderReadRecovered` helpers — one outage, one line, one vocabulary. -Callers that relied on `listNames` throwing to detect an outage should read -`listDiagnosed()`, which reports `degraded` explicitly. - -**New: `MetadataManager.loadManyKeyed(type, options?)`** — `loadMany` read under -the identity the STORE holds each item by, returning `{ name, data }` pairs. It -delegates to a loader's own `loadManyKeyed` where one is offered (on -`DatabaseLoader` that shares `loadMany`'s single query, so it costs nothing -extra) and otherwise falls back to that loader's `list()` + per-name `load()`. -⛔ **`loadMany`'s published return shape does not change**, and no existing -consumer is touched: the key travels *beside* the body, never inside it, so a -body that deliberately carries no `name` stays byte-identical to what was -stored (#14205). - -**The action-governance audit now mirrors the router on both halves of the D5 -bijection.** The declaration half enumerates the plane keyed -(`loadStandaloneActionsKeyed`), so a row whose body does not name itself — a -`sys_metadata` row keyed by its `name` column, or a `FilesystemLoader` file -whose identity is its path — is a declaration to the audit exactly as it is to -the router; the handler half also probes the plane BY NAME -(`lookupMetadataAction`, `loadDiagnosed`/`load`, injected like the existing -registry rung), so a loader fault a plural read swallows can no longer turn a -dispatchable handler into an accusation. Both probes stay conservative in one -direction only: a source that throws leaves the handler on the list. - -Additive on every published signature. `runActionGovernanceInventory` and -`collectEngineActionDeclarations` gain optional parameters and keep their old -ones working unchanged; declaration rows gain an optional `storeKey` (the new -exported `ActionDeclarationRow`). - -**Population change, reported:** `unboundDeclarations` now sees declarations -whose identity is the store key. Its BEFORE was **0, structurally rather than -by sampling** — a nameless row was dropped before reconciliation ran, so it -could never be reported however many a plane held. Its one deliberate -subtraction: a row with neither an own `name` nor a store key is no longer -reported as `actionName: undefined`, which read as a parse failure in the -warning rather than as a finding. - -Known boundary, stated in the audit's docblock rather than left to be -rediscovered: a boot-time audit runs outside any request scope, so if a -composition ever registered `metadata` as `SCOPED` the audit could not reach -that instance at all — before any read method runs. No shipped composition does -(`packages/metadata/src/plugin.ts` registers a static instance), and reaching a -request-scoped service from a boot-time audit is a separate change. diff --git a/.changeset/auth-catchall-owned-404-not-yielded.md b/.changeset/auth-catchall-owned-404-not-yielded.md deleted file mode 100644 index 4be96afaec..0000000000 --- a/.changeset/auth-catchall-owned-404-not-yielded.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -The auth catch-all yields only a 404 that disclaims ownership — better-auth's own 404 answers can no longer be replaced by another route's - -`registerAuthRoutes` mounts one catch-all over the whole auth namespace (`rawApp.all(`${basePath}/*`)`), and since #4088 that catch-all is deliberately not terminal: when better-auth answers 404 it calls `next()` and lets whatever else matched answer instead. That yield is load-bearing — `plugin-hono-server` mounts `/auth/me/permissions` and `/auth/me/localization` from its own `kernel:ready` hook, and without it those two are reachable only when HonoServerPlugin happens to register first. - -What the yield could not express is **which** 404 may be handed on, because it had only the status to go on. So every 404 was yielded, including the ones that are better-auth's own answer on a path its router serves. Measured with the shipped handler on a real Hono app: add one broad downstream mount — `app.all('/api/v1/*', c => c.json({}))`, the shape a composition adds — and - -``` -POST /api/v1/auth/delete-user -> 200 {} -``` - -where better-auth answered 404 because `user.deleteUser` is deliberately unconfigured. That route is not hypothetical: `auth-route-ledger.ts` carries it under the `disabled` disposition precisely because it is published and refused — and the same holds for every 404 a routed endpoint produces for a bad token, an unknown id, or an admin family the deployment does mount. Those answers were all up for grabs. - -The catch-all now asks better-auth's live instance whether it owns the path before it yields. The seam is `auth.api` — the same one `auth-route-ledger.conformance.test.ts` reads and the same one the `/admin/` dogfood sweep derives from, because there is no route table to enumerate by hand; matching mirrors better-call's own `createRouter` walk, including its `SERVER_ONLY` skip and its `:param` syntax. That skip is load-bearing rather than cosmetic: measured on the stock boot, the nine `/admin/oauth2/*` endpoints are in `auth.api` and every one carries `SERVER_ONLY: true`, so better-call never routes them — their 404 is an unrouted one and stays yieldable, because ownership is "does better-call route this", not "is it in `auth.api`". An ownership table that cannot be built answers "not owned", so an enumeration failure degrades to the previous behaviour rather than taking the #4088 surface down with it. - -**The mount is untouched.** It still claims exactly `${basePath}/*` and still forwards every request under it to better-auth. What narrowed is only which 404 may be handed on. - -**Upgrade note — a composition that mounts a route matching paths under the auth base path may see a 404 where it previously saw its own answer.** Affected: deployments that register a route which also matches `/api/v1/auth/...` — most often a broad wildcard over the API prefix — mounted *after* AuthPlugin. Before this release, any request to a path better-auth serves but answers 404 on (a switched-off capability, not an unknown path) was passed to that route and the caller received *its* response, commonly `200` with an empty object. From this release the caller receives better-auth's 404. Callers that treated such a response as success — `res.ok`, `status === 200`, "no error thrown" — will start seeing the refusal that was always the real answer; that is the point of the change, and the wire shape they now get is the one a deployment without the extra mount has always returned. Nothing to do if you mount no such route: paths better-auth does **not** own are yielded as before, so `/auth/me/permissions`, `/auth/me/localization` and any other sibling route under the auth prefix are unaffected in either registration order. - -**One carve-out to that sentence, measured and bounded.** A **trailing-slash or doubled-slash spelling of a path better-auth DOES own** — `/api/v1/auth/delete-user/`, `/api/v1/auth//sign-in/social` — is now claimed rather than yielded. better-call treats those spellings as unrouted (it refuses on a `//` and on trailing-slash parity before it looks the route up), while this ownership table strips the trailing slash and drops empty segments and so counts them as owned. On a composition with a broad downstream mount, such a spelling therefore answers better-auth's 404 instead of that mount's response. Only those two spellings, only of a path better-auth already owns, and only where such a mount exists: no route in this repo registers a spelling of that shape, and every genuinely unowned path — every `/auth/me/*` route included — is yielded exactly as it was. Aligning the table with better-call's own pre-checks is tracked as a follow-up rather than carried here. diff --git a/.changeset/auth-domain-claim-segment-boundary.md b/.changeset/auth-domain-claim-segment-boundary.md deleted file mode 100644 index 50b96f7344..0000000000 --- a/.changeset/auth-domain-claim-segment-boundary.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The `/auth` dispatcher domain no longer claims sibling namespaces such as `/authx` and `/authentication/foo`. - -`createAuthDomain` registered `{ prefix: '/auth' }` without a `match`, and `DomainRoute.match` defaults to `'prefix'` — a bare `path.startsWith('/auth')` with no segment boundary. Every path whose first segment merely *began* with the five characters `auth` was therefore claimed by the auth domain and forwarded to the auth service, instead of falling through to the dispatcher's `ROUTE_NOT_FOUND`. Measured on a real boot (a real kernel with `AuthPlugin`, served through `createHonoApp({ kernel, prefix: '/api/v1' })`), `GET /api/v1/authx`, `/api/v1/authx/foo` and `/api/v1/authentication/foo` were all claimed; `/api/v1/aut/foo` and `/api/v1/zzz/foo` were not, which is what located the boundary at the `auth` prefix. - -The route now declares `match: 'segment'` — the spelling the registry's other boundary-correct domains (`/keys`, `/mcp`, `/mcp/skill`) already use. It claims `/auth` exactly and everything under `/auth/`, and nothing else. - -**What does not change.** `/auth/me/permissions` and `/auth/me/localization` still reach `dispatch()`. Neither is a better-auth endpoint, so the adapter's `/auth/*` mount disclaims them and they arrive at this domain; `'segment'` keeps claiming them, which the accompanying test pins as an overshoot control alongside the three narrowed rows. - -**If you mounted a namespace under `/authx`, `/authentication`, or any other first segment starting with `auth`,** it was previously shadowed by the auth domain and answered by the auth service. It is now reachable — register a domain handler for it, or expect `ROUTE_NOT_FOUND`. diff --git a/.changeset/automation-completed-run-history-throw.md b/.changeset/automation-completed-run-history-throw.md deleted file mode 100644 index af2e4fd936..0000000000 --- a/.changeset/automation-completed-run-history-throw.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run whose nodes all succeeded is no longer reported `stranded`, journalled for repair, or re-armed because its terminal run-history write threw — which made the "repair" re-run every node after the pause. - -`AutomationEngine.resumeInternal` called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. The arm journalled a repair snapshot, stamped `status: 'stranded'`, and answered `success: false`; `restoreConsumedSuspension` then correctly honoured that snapshot and put the pause back, so the next resume drove the downstream nodes a **second** time. The defence that normally prevents a re-armed run from becoming double-runnable reads the durable terminal row first, and on this path the durable terminal row is precisely what failed to land — so it could not fire. - -Two statements inside `recordLog` reach that `catch`, and both are host-supplied surfaces rather than in-repo ones. `store.recordTerminal` escapes when it throws **synchronously**: the `void write.catch(...)` beneath the call only ever sees a returned promise's rejection, and a store returning a non-thenable makes `write.catch` itself a synchronous `TypeError`. Both stores shipped in this package are `async` methods and so cannot reach it, but `SuspendedRunStore` is an exported, optional-method interface a host may implement. The second statement is the run-summary line `logger.info(...)`, which is on by default (`runSummaryLog: 'info'`) and calls a host-injected `Logger`, so it needs no store at all. - -- **The completion-path history write is now guarded at its own site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. `resume` answers the truthful `success: true`, no snapshot is journalled, `restoreConsumedSuspension` refuses with `RUN_COMPLETED`, and the downstream node runs exactly once. -- **The lost history row is still reported**, at `error`, naming in its first line that the run completed, that its terminal row never landed and nothing retries it, that the run must not be repaired or re-run, and that the driver's own failure is in the record's structured slot. -- **`restoreConsumedSuspension` is unchanged.** It judged correctly on the evidence it was handed; the evidence was what was wrong, and a completed run now journals none. A genuine node failure still journals, still reports `stranded`, and is still repairable. diff --git a/.changeset/automation-failed-save-reseats-suspension.md b/.changeset/automation-failed-save-reseats-suspension.md deleted file mode 100644 index 030f570b9d..0000000000 --- a/.changeset/automation-failed-save-reseats-suspension.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A suspended run whose durable save fails is now kept resumable in this process even when a concurrent read landed mid-park — and the error record that reports the failure says how to check that. - -`AutomationEngine.persistSuspendedRun` writes its map entry BEFORE it awaits `store.save()`, and marks the run cache-only only once that save settles. For the whole of that await the entry is live but unqualified, so a concurrent per-id `hasSuspendedRun` / `resume` reads a store that truthfully has no row yet, finds no qualifier, and evicts a run that is being parked right now. That window is bounded and stays as it was: the strict load is store-first, so once the save lands the run is resumable from the store, and only the cache-only listing under-reports. - -Compounded with the save then **failing**, it was not bounded. The catch marked the run cache-only, but the map entry that marking qualifies had already been evicted, so the run had neither a durable row nor an in-memory copy: `hasSuspendedRun` answered `false` and `resume` answered `RUN_NOT_FOUND`. The run was lost **in this process**, not merely un-durable — for example a paused approval that no decision can ever advance. Reaching it needs a store that rejects the write while still answering reads with "no row" rather than throwing: a healthy read replica behind a broken write path, a missing `INSERT` grant, a full disk. - -- **The failure path now re-seats the map entry** alongside the cache-only marking, so the marking qualifies something again and the documented degradation — a failed save costs cross-restart durability, not in-process resumability — holds in this interleaving too. The cache-only marking is not widened, no lock is added, and the save is not reordered, so a run is still never readable out of the map while the store is authoritative for it. -- **The error record for a failed save is corrected.** It kept telling the operator the run was "kept in memory only" and that they had until the next restart to act, which in this interleaving pointed away from the loss: the run was already gone, and the restart would take the blame. It now names the two reads that must still answer for the run (`hasSuspendedRun()` and `listSuspendedRuns()`), so the promise can be checked rather than trusted. It still reports the same cause in the same structured slot, at the same `error` level. diff --git a/.changeset/batch-row-unique-violation-metadata-protocol.md b/.changeset/batch-row-unique-violation-metadata-protocol.md deleted file mode 100644 index 918ad7a355..0000000000 --- a/.changeset/batch-row-unique-violation-metadata-protocol.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol)!: a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723) - - - -**BREAKING** on the per-row report of `POST /api/v1/data/:object/batch` (and -the multi-object `POST /api/v1/batch`, which rides the same protocol): a row -refused by the engine's `DuplicateRecordError` envelope now reports -`errors[].code: 'UNIQUE_VIOLATION'` where it reported `'DUPLICATE_RECORD'`. -Shipped as `minor` under the repo's launch-window convention for breaking -changes. Maintainer ruling 2026-09-03 on #14723 (verbatim 「同意,然后执行契约 -复审」), adopting option A: one wire spelling for a unique-constraint refusal on -every route. - -**Why.** `toRowApiError` put a thrown REGISTERED code on the row verbatim, and -`DUPLICATE_RECORD` is registered, so a `DuplicateRecordError` row said -`DUPLICATE_RECORD` while the whole-request failure on the very same route (the -bulk door's classification in `@objectstack/rest`) answered `UNIQUE_VIOLATION` -— the standard-catalog member `content/docs/protocol/kernel/http-protocol.mdx` -documents for the 409 constraint-violation body. Since the bulk doors were -restored to `UNIQUE_VIOLATION`, the two spellings of one condition sat side by -side in one route's responses, which ADR-0112's one-name-per-concept and the -error-code ledger's own header both forbid. The duplication is removed, not -declared: no ledger waiver is added. - -**What changes.** The row derivation recognises the engine's envelope by the -same two-part gate the whole-request arm uses — the registered code AND the -class name `DuplicateRecordError`, never message text — and reports -`UNIQUE_VIOLATION`. Everything else on the row is unchanged: `httpStatus: 409`, -the platform sentence (no driver text, no bound value — the driver's error -stays on `cause` and never reaches the row), and the sibling `NOT_ATTEMPTED` / -`ROLLED_BACK` rows. - -**What does NOT change.** The engine's thrown identity: `DuplicateRecordError.code` -is still `DUPLICATE_RECORD` for an in-process caller of `engine.insert` / -`engine.update` (a hook, a flow node), and the objectql pins on `insert` / -`insertMany` hold. The single-record `/data` door, which has answered -`UNIQUE_VIOLATION` throughout, does not move. A producer that merely THROWS the -registered `DUPLICATE_RECORD` from its own body without being the engine's -class keeps its own code on the row, exactly as it does at the door. - -**Consumer note.** A batch client that branched on a row's `code` reading -`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it -already handles for the whole-request 409 on that route and on the -single-record door. Measured in-repo and in the sibling repos (hotcrm, objectui, -non-test sources): zero consumers branch on either spelling of a row code. diff --git a/.changeset/blueprint-strict-mirror-value-parity.md b/.changeset/blueprint-strict-mirror-value-parity.md deleted file mode 100644 index 2cb54a4e52..0000000000 --- a/.changeset/blueprint-strict-mirror-value-parity.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects. - -`SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured. - -The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen. - -Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier. - -A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair. - -Refs cloud#1967. diff --git a/.changeset/boot-sign-in-report-remedy-text.md b/.changeset/boot-sign-in-report-remedy-text.md deleted file mode 100644 index 2f330fb333..0000000000 --- a/.changeset/boot-sign-in-report-remedy-text.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -The `no_sign_in_account_at_boot` report now names a remedy that works — and warns off the one that silences the report itself. - -That boot line fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said: - -- **"Open the audience posture so an existing person can register their own login"** produced no login, and for an existing person it never can: self-registration is a user-creation path, so it cannot attach a login to an address that already carries a `sys_user` row, whatever the posture. Widening only ever admits a *new* address — and then every posture other than `invite_only` forces `requireEmailVerification` on, so that login is refused `EMAIL_NOT_VERIFIED` at its first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired. -- **"Write a `sys_account` credential row directly against the store"** was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and the probe behind this report asks only whether *any* `sys_account` row exists, so writing one turns the report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end. - -The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — a lowercase address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door needs widening. It is an admission verdict and not a verification bypass, though, so the line scopes what follows from that: only under the default `invite_only` posture is the recovery mail-transport-free, and it tells the operator to close a widened posture back to `invite_only` before the invited person registers — otherwise the invited login is created, refused `EMAIL_NOT_VERIFIED` at first sign-in, and has silenced this report on the way past. On the `single` tenancy posture that account holder is then promoted to platform admin. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe. - -**Message text only — no admission semantics move.** Nothing widens, nothing narrows, no accept set changes, and the probe is untouched: this changes what an operator *reads*, not what the platform *admits*. The long form of the same three facts is on the self-hosting deployment page. diff --git a/.changeset/bulk-event-batch-organization.md b/.changeset/bulk-event-batch-organization.md deleted file mode 100644 index e82c5e2340..0000000000 --- a/.changeset/bulk-event-batch-organization.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch - -`BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the -contract half) is one organization for a whole predicate write, or absent. The -only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches -of `update()` / `delete()` — never set it, so every `data.records.updated` / -`data.records.deleted` event read "not asserted" and a tenant-scoped consumer -could deliver nothing per organization on the bulk path. This is the bulk half -of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`) -landed separately. - -The producer now stamps the key from what it already holds — no second query -on the publish path: under `isolated` the caller's active organization (the -Layer 0 wall's equality term), under `group` the caller's membership set when -it names exactly one organization. It is OMITTED — never the caller's active -organization standing in — on a `single`-posture deployment, on an `isSystem` -context (no wall composed), on a multi-membership `group` sweep, when no -enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is -deliberately not consulted), when the caller may have crossed the wall as a -`PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall -does not key on. `absent` here means "the producer did not assert one -organization for the batch", deliberately NOT the `DataEvent` reading -"belongs to no organization". - -Which objects "the wall does not key on", stated exactly rather than claimed as -a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled` -input is true or the object carries no `organization_id`, and it folds THREE -clauses into `tenancyDisabled` — `tenancy.enabled === false`, -`systemFields.tenant === false`, and the deployment's `platformGlobalObjects` -carve-out. The producer reads the registry's binding of that predicate -(`carriesTenantScopeColumn`: the first two clauses plus the column clause) and -answers absent on a federated (`external`) object; a custom -`tenancy.tenantField` is therefore not an exit by itself — the object is walled -iff it carries `organization_id`, and the key follows the wall. The third -clause is deployment-declared and not readable by the engine: a -deployment-exempted object under an armed wall is still stamped with the -caller's organization by this producer alone, and that population's exact -answer is decided by the seam ruled on in #15706. - -`patch`, not `minor`: the act adds no member to this package's published -surface. `carriesTenantScopeColumn` is exported at module level inside -`registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export -named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and -both entries' runtime export lists are unchanged (measured on the built `dist`, -with a firing control) — and the emitted event's member was declared, typed -and paid for at `minor` by the spec half. Producer conformance to an existing -optional member under `fix(` changes no public surface of this package. diff --git a/.changeset/cast-blob-compile-option-claim.md b/.changeset/cast-blob-compile-option-claim.md deleted file mode 100644 index 62ff4360e6..0000000000 --- a/.changeset/cast-blob-compile-option-claim.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/driver-turso": patch -"@objectstack/driver-sql": patch -"@objectstack/service-analytics": patch ---- - -Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. - -Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. - -No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. - -Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. diff --git a/.changeset/chart-config-missing-overreach.md b/.changeset/chart-config-missing-overreach.md deleted file mode 100644 index c02a356bf6..0000000000 --- a/.changeset/chart-config-missing-overreach.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`chart-config-missing` no longer fires on a widget whose binding the renderer derives - -The rule warned on every chart-family widget that declared no `chartConfig`, on the -stated grounds that "the renderer cannot determine which measure to plot, so the series -renders empty". Measured against the `@object-ui` revision this repo pins -(`.objectui-sha`), that consequence is false: `DatasetWidget` derives the x-axis key and -one series per measure from the widget's own `dimensions` / `values` via -`buildChartSeries`, and refuses an authored `ChartAxis.field` / `ChartSeries.name` -outright — `chartConfig` carries presentation only. The renderer pins this by name: -"ignores an authored axis `field` and keeps the derived axis binding", "ignores an -authored series and keeps one derived series per measure", "emits none of the -presentation keys when no chartConfig is declared". - -The false finding was landing on this platform's own shipped metadata — the -`system_overview` dashboard's pie and bar tiles, on the Setup board every customer opens -first — which is the ADR-0072 D1 cost the rule family exists to avoid. - -The rule id is unchanged and keeps one true arm: a `combo` widget with no `chartConfig`, -whose per-series mark is authored as `chartConfig.series[].type` and has no other -channel, so every measure draws with the same default mark and the chart is not a -combination at all. Its message now names that consequence instead of the binding. -An existing `suppressWarnings: ['chart-config-missing']` entry stays valid. diff --git a/.changeset/chart-empty-selection-rules.md b/.changeset/chart-empty-selection-rules.md deleted file mode 100644 index 0a290341c1..0000000000 --- a/.changeset/chart-empty-selection-rules.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -Two new widget-binding rule ids for a chart widget with an empty selection - -`validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the -`@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now -warnings, suppressible per widget with `suppressWarnings: ['']`: - -- `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or - absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures - (values) for this dataset widget." before any query runs, above every family branch, so - no chart is drawn at all. -- `chart-dimensions-missing` — a chart-family widget selects at least one measure but no - dimensions. `DatasetWidget.tsx:423` reads - `const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the - widget renders as a single KPI number and the declared chart family is silently ignored. - The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches - what actually renders. - -Warning tier rather than error for both: an empty selection is a work-in-progress state a -build must tolerate, and erroring would gate the `sys_metadata` publish path on a -half-authored widget. Neither shape is folded into `chart-config-missing` — neither is -caused by, nor repairable with, `chartConfig`, which carries presentation only. - -"Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that -the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own -`METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot` -tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview` -board's own KPI tiles, is therefore not a finding. diff --git a/.changeset/chart-field-unknown-refused-binding-tier.md b/.changeset/chart-field-unknown-refused-binding-tier.md deleted file mode 100644 index f18c3f48ca..0000000000 --- a/.changeset/chart-field-unknown-refused-binding-tier.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens - -The rule id covers exactly three positions, and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so none of them can produce the data failure the messages described: - -- `chartConfig.xAxis.field` — `axisPresentation` (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis presentation **minus** its `field`. The x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`; an authored `field` re-points nothing. -- `chartConfig.yAxis[].field` — the same call, per entry. The entry keeps its slot (the count is what turns on a secondary axis) and its scale and chrome; only the binding is dropped. -- `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored entry with the derived series whose `dataKey` it equals, one per entry of `values`. An entry naming no derived series is ignored whole, so the presentation hung on it — the mark, the colour, the stack, the axis side — lands on nothing. - -The renderer pins this by name in `DatasetWidget.chartConfig.test.tsx` ("ignores an authored axis `field` and keeps the derived axis binding", "ignores an authored series and keeps one derived series per measure"). - -So the old message — "the query result will not contain it" — named a query failure that never happens, and `error` blocked a build and a Studio publish for a key that changes nothing at runtime. That is the class `widget-legacy-analytics-shape` reports at `warning` in the same file ("the dashboard renderer ignores them … a silent no-op"), and this id now carries the same tier, the same suppressibility (`suppressWarnings: ['chart-field-unknown']` per widget) and the same kind of sentence. Each message states its own consequence, because the axis positions and the series position are refused for different reasons. - -The finding is **kept**, not deleted: unlike the `chart-config-missing` over-reach this measurement came from, the metadata really is wrong — the author wrote a binding and believes it is in force. - -## Migration - -**A publish that used to be refused now succeeds.** Ruled 2026-08-15, `validateWidgetBindings` put its whole error set on the `sys_metadata` publish door (Studio / REST `/meta` / MCP) as one "this board cannot render" reference-integrity class. That class was six ids and is now five — `chart-field-unknown` has left it. A dashboard write whose only reference-integrity problem is a refused `chartConfig` binding key is no longer a 422 `INVALID_METADATA`; it publishes, and the finding rides the non-blocking `advisories` channel on the 2xx response instead. The other five (`widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`) are unchanged. - -Same direction on the CLI: `os validate` / `os build` / `os lint` report the finding at `warning`, so a stack that used to fail the build over one of these keys now exits 0 with an advisory. If you were relying on the build to stop on it, add the key to your own gate, or fix the binding — the fix has not changed: - -- point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only); -- point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only); -- name a selected measure in `series[].name`, remembering that post-cutover (ADR-0021) result rows are keyed by the dataset's measure **name** (`sum_amount`), not the base column (`amount`). - -A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`. diff --git a/.changeset/chart-measure-unknown-presentation-positions.md b/.changeset/chart-measure-unknown-presentation-positions.md deleted file mode 100644 index 4f1d16e175..0000000000 --- a/.changeset/chart-measure-unknown-presentation-positions.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`chart-measure-unknown` no longer blocks a build over a chart `series[].name` (or a page chart's `yAxis[].field`) that names nothing — those positions are presentation, and the message now says so. - -The rule fired at `error` on every measure position of the three chart surfaces it covers, with one consequence sentence: *"result rows are keyed by MEASURE NAME … so this series comes back empty"*. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is true only where the position feeds the dataset query, and the three surfaces do not agree: - -- **Report charts** run the chart's own query out of the two axis strings (`useDatasetRows(dataset, [xAxis], [yAxis], …)` — *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*), so `chart.xAxis`/`chart.yAxis` are the binding. `chart.series[]` is *"the author's per-chart override for ONE measure's display name"*, lowered through `mergeAuthoredSeries`, where *"an authored entry naming a measure that is NOT in the dataset selection is **ignored** — membership belongs to the dataset"*. -- **List-view charts** have no presentation position at all: `ListChartConfigSchema` is a strict object of `chartType`/`dataset`/`dimensions`/`values`, and `values[]` is handed to the chart as the dataset measures. -- **Dataset-bound page chart components** query `{ dimensions, measures: values }` and then replace the authored series wholesale with one derived entry per selected measure, so `properties.series[].name` reaches the renderer not at all and `properties.yAxis[].field` re-points nothing. - -**Behaviour change users see:** the three presentation positions — report `chart.series[].name`, page-component `properties.series[].name` and `properties.yAxis[].field` — drop from `error` to `warning`. A build or a metadata publish that used to be refused because of one of them now succeeds, with the finding on the advisory channel. The finding is KEPT, not deleted: the metadata really is wrong — the author wrote a key and believes it is in force. Every query position (report `chart.yAxis`, and `values[]` on all three surfaces) keeps `error` and its existing message verbatim. - -Two smaller corrections ride along, both from the same read: - -- The page surface's `yAxis[].field` refs are no longer concatenated into the `series[]` limb before the measure walk, so an axis position no longer takes the series message. Reading both shapes on that surface stays deliberate; giving them one sentence was not. -- `chart-axis-not-selected` (a declared measure outside the selection) took the same one-size consequence, *"the query does not return it, so the series plots nothing"*. It keeps that wording at a query position and states the real one at a presentation position, where no series is derived for the name in the first place. - -Note that none of these three surfaces declares `suppressWarnings` — it is a dashboard-widget key — so the new advisories cannot be individually silenced; the hint says so instead of pointing at a key that does not exist. diff --git a/.changeset/claim-capability-probe-before-mutate.md b/.changeset/claim-capability-probe-before-mutate.md deleted file mode 100644 index 96c34c184b..0000000000 --- a/.changeset/claim-capability-probe-before-mutate.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -`ObjectStoreSuspendedRunStore` no longer announces "no cross-replica advance guarantee is offered" *after* it has issued the guarded delete. - -`claimSuspension` is the cross-replica half of the resume idempotency guard: it removes the `sys_automation_run` row only if the run is still parked where this replica read it, and the affected-row count names the winner. The refusal for an engine that does not resolve such a count was decided on the SHAPE of the return value — one line after the compare-and-set had already gone out. On such an engine that made the refusal a statement about a write that had already landed: the conditional delete was performed against the shared row and its verdict discarded, `AutomationEngine.claimAdvance` read `'unsupported'` as `unguarded`, and a replica that **actually lost** the claim (0 rows affected) resumed anyway — running every downstream side effect a second time, on the one composition that declares itself unable to prevent that. - -The capability question is now settled before anything is claimed, and `'unsupported'` is retired as an answer once the row has been touched: - -- **A one-time capability probe, before the compare-and-set.** Once per store instance, `claimSuspension` issues one delete down the very route the claim takes (`multi: true` with a `where` carrying keys besides `id`, which is what dispatches to `driver.deleteMany`) against a sentinel predicate that matches no row — the same value in `id`, `node_id` and `correlation` at once. An engine that resolves something other than a count is refused with **nothing consumed**, so `claimAdvance`'s `unguarded` reading is true when it is taken. Concurrent first claims share one probe, and a probe that *throws* is deliberately not memoized: a store that was unreachable for one second must not answer for the life of the process. -- **After the write, an unreadable verdict is `STORE_UNAVAILABLE`, not `unguarded`.** If a probed-counting engine still resolves a non-count for a real claim, the compare-and-set is committed and its verdict is unrecoverable — a winner and a loser both find the row gone, so no follow-up read can tell them apart. The store throws instead of answering `'unsupported'`; `claimAdvance` already maps that to `STORE_UNAVAILABLE`, whose text is written for exactly this fact ("a failure can arrive after a committed delete"), and the resume is **refused** rather than continued. A claim that in fact won is then stranded until an operator retries — the deliberate direction, since a doubled side effect is the worse outcome. - -**What this does not do, stated so it is not read into it.** It does not give an uncounted engine the guarantee. `ObjectQL.delete` declares `Promise`, so "does a multi-delete return a count" has no contractual answer to look up and no read-only instrument to measure — a probe can observe the route once, never promise what the next call resolves to. Closing that gap belongs to the engine boundary, where the count is contracted one layer down (`IDataDriver.deleteMany`, `Promise`) and erased to `any` on the way up. On such a composition the store still degrades to an unguarded resume; what changed is that it says so before consuming anything, and the run's durable row is still removed by the consumption choke point exactly as before. - -Every measured shipped composition already resolves a count (memory, sql/better-sqlite3, sqlite-wasm, turso local and remote transport, sql with the security plugin composed), so the observable cost there is one extra `DELETE … WHERE` that matches nothing, once per process. It emits no hook dispatch, no realtime event and no row change: the per-row before phase is "zero matched rows is zero dispatches", the after phase iterates the same empty set, and `publishBulkDataEvent` returns at `matched === 0` by design. diff --git a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md deleted file mode 100644 index 148570312c..0000000000 --- a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) - -The dashboard key catalogue `os explain` prints lists -`refreshIntervalSeconds` instead of `refreshInterval`, following the -`@objectstack/spec` rename of the authored key (the unit now lives in the key -name). Same key, same seconds; no other command output and no public surface of -this package changes. diff --git a/.changeset/cli-hook-body-subpath-export.md b/.changeset/cli-hook-body-subpath-export.md deleted file mode 100644 index af97e82057..0000000000 --- a/.changeset/cli-hook-body-subpath-export.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again. - -`minor`, not `patch`: a new subpath on a published package's `exports` map is a purely additive widening of its public surface — a new accepted key — which takes at least `minor` under the maintainer's 2026-09-04 rule (decision batch #35, on #15294) in the Check Changeset step's "WHICH LEVEL" prose; the commit type never lowers it. diff --git a/.changeset/cli-lint-strict-warnings-fail.md b/.changeset/cli-lint-strict-warnings-fail.md deleted file mode 100644 index 62a098d4ae..0000000000 --- a/.changeset/cli-lint-strict-warnings-fail.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935) - -Only an `error` failed `os lint` before. `packages/lint` ships ≈250 authoring rules, 119 of them at `warning`, and a run with any number of warnings and no errors exited 0 — so an app that wanted one of those rules to gate its CI had to re-implement it locally at error level, or bolt a script onto the JSON output to promote a family by hand. - -New public flag: **`os lint --strict`**. With it, a run with one or more `warning`-severity findings exits 1 exactly as an `error` does, and the console says why, naming the count and the flag: - -``` -✗ 1 warning(s) fail this run under --strict (a warning is advisory without the flag) -``` - -`suggestion`s stay advisory under both. ⛔ The default is unchanged: without the flag the same stack still exits 0, and no existing `os lint` expectation moves. - -The `--json` face carries the verdict so a gate can read it without re-deriving it from the counts. Two keys, unconditionally present on every project-lint payload, flag or no flag: - -```json -{ "passed": false, "errors": 0, "warnings": 1, "suggestions": 0, "strict": true, "failing": 1 } -``` - -`strict` says whether the flag was in effect; `failing` is the count the exit code was read from — `errors`, or `errors + warnings` under `--strict`; and `passed` is `failing === 0`, the same statement the exit code makes — so `--strict --json` on a warning-only stack reads `passed: false` beside exit 1, never `passed: true` next to a failing exit. - -Not in this change: per-rule severity configuration, any change to a rule's severity, and `--eval` mode, which keeps its own pass bar (`--eval-min`). diff --git a/.changeset/cli-migration-audit-stamp-nullability-and-default.md b/.changeset/cli-migration-audit-stamp-nullability-and-default.md deleted file mode 100644 index 988625a161..0000000000 --- a/.changeset/cli-migration-audit-stamp-nullability-and-default.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os generate migration`: the builtin `created_at` / `updated_at` columns now match `driver-sql` on nullability and default text, and the SQL format declares itself PostgreSQL-only. - -Both formats emitted `NOT NULL` on the two audit-stamp columns while the driver creates them nullable, and the SQL format spelled their default `now()` while both knex producers emit `CURRENT_TIMESTAMP`. Nothing failed either way, but `information_schema` kept the pair textually apart forever, so a schema diff between a generated table and a platform-created one was permanently noisy. Both generators now follow the driver — the same rule the `id` column already follows — and `--format sql` states in its help text and its docblock that it targets PostgreSQL only and makes no MySQL or SQLite claim. diff --git a/.changeset/cli-package-publish-help-local-dev-example.md b/.changeset/cli-package-publish-help-local-dev-example.md deleted file mode 100644 index 6d22c35109..0000000000 --- a/.changeset/cli-package-publish-help-local-dev-example.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os package publish --help` no longer points its local-dev example at a directory this repo does not have. - -The last line of the command's `EXAMPLES` block read: - -``` -$ OS_CLOUD_URL=http://localhost:4000 os package publish # local dev (apps/cloud) -``` - -`apps/cloud` was deleted from this repository — the reference cloud host now lives in `objectstack-ai/cloud` — so the parenthetical sent a reader to a path that is not in the tree they cloned. This is help text, not a source comment: it is printed verbatim to anyone who runs the command. - -The parenthetical is dropped rather than re-pointed at the other repo. The example is about `OS_CLOUD_URL` overriding the control-plane URL, which the `--server` flag already documents in the same output; which directory happens to serve `localhost:4000` was never part of what the example teaches, and a `--help` reader is not looking for a file in a monorepo. `# local dev` alone carries it, and it now matches how the CLI reference docs have long published the same example. - -No behaviour changes: `examples` is a static help string, and no flag, argument, default or exit code moves. diff --git a/.changeset/client-oauth-family-wire-shape-binding.md b/.changeset/client-oauth-family-wire-shape-binding.md deleted file mode 100644 index 48bc5fa893..0000000000 --- a/.changeset/client-oauth-family-wire-shape-binding.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client)!: the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312) - -**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. - - - -Card 1 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, and better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, not the wire fact. - -## What changed - -Four methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change: - -| method | resolved to (before) | resolves to (now) | -|:--|:--|:--| -| `client.oauth.applications.register(req)` | `any` | `OAuthApplicationRegistration` | -| `client.oauth.applications.get(id)` | `any` | `OAuthApplication` | -| `client.oauth.applications.getPublic(id)` | `any` | `OAuthApplicationPublic` | -| `client.oauth.consent(req)` | `any` | `OAuthConsentResult` | - -`OAuthApplication`, `OAuthApplicationRegistration`, `OAuthApplicationPublic` and `OAuthConsentResult` are newly exported from `@objectstack/client`. These four routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap, and none is introduced. - -## The exact reads that stop compiling - -Everything below compiled before only because `any` is assignable to, and indexable by, everything. - -```ts -const app = await client.oauth.applications.get('c_1'); -app.data; // was fine; now TS2339 — these routes carry NO envelope -app.anythingAtAll; // was fine; now TS2339 - -const pub = await client.oauth.applications.getPublic('c_1'); -pub.client_secret; // now TS2339 — the public projection hand-picks 7 columns -pub.grant_types; // now TS2339 — same reason -pub.disabled; // now TS2339 — same reason - -const decision = await client.oauth.consent({ accept: true }); -decision.client_id; // now TS2339 — consent answers `{ redirect, url }` - -// Timestamps are RFC 7591 NUMBERS (Unix epoch seconds), so a caller that -// guessed `Date` or ISO `string` now fails: -new Date(app.client_id_issued_at!).toISOString(); // TS2769: number is not a Date arg -app.client_id_issued_at!.slice(0, 10); // TS2339: not a string -new Date(app.client_id_issued_at! * 1000); // the correct rewrite -``` - -A caller that only read `client_id`, `client_secret`, `redirect_uris` or `url` needs no change. - -## Timestamps: `number`, not `Date` and not ISO-8601 - -The ruling ordered every `Date`-typed field declared as an ISO `string` and forbade both a `Date` declaration and a runtime revival layer. **This family has no `Date` field to convert.** RFC 7591 carries `client_id_issued_at` and `client_secret_expires_at` as Unix-epoch SECONDS, and the provider converts its stored `Date` to a number before serialising, so the wire sends neither a `Date` nor an ISO string. Both are declared `number`, and a type-level pin holds them there. The ruling's prohibitions are satisfied: nothing declares a `Date`, and no revival layer exists. - -## Two places better-auth's own types were the wrong answer - -Read off the wire against a real server, not off the vendor's `.d.ts`: - -- `getPublic` is declared `OAuthClient` — the full row — but its handler hand-picks seven columns. `OAuthApplicationPublic` is that projection, derived with `Pick` so it cannot drift from its parent. Its `redirect_uris` is always `[]` on this route and carries no information. -- `user_id` and `application_type` are declared nullable by the vendor, but the serialiser folds a null column to `undefined`, so `null` is unreachable and is not declared. - -## `oauth.applications.delete` is deliberately NOT bound - -The fifth method of the family keeps its `Promise< any >` and its ledger entry. Its route answers HTTP 200 with a zero-byte body, so its `res.json()` rejects with a `SyntaxError` on every successful delete. No annotation can be honest while that call stands, and binding it needs a behaviour change — a decision beyond this card's type-narrowing scope. That the shrink-only ledger still carries exactly this one entry is the mechanism working. diff --git a/.changeset/client-readme-analytics-automation-payload-reads.md b/.changeset/client-readme-analytics-automation-payload-reads.md deleted file mode 100644 index 8afd188304..0000000000 --- a/.changeset/client-readme-analytics-automation-payload-reads.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/client": patch ---- - -The README's analytics and automation examples read the resolved payload. - -`client.analytics.query` / `analytics.meta` and `client.automation.trigger` stopped handing back the dispatcher's `{ success, data }` envelope in 17.0.0: each resolves to the payload itself. The README's namespace tour still showed all three as bare `await` calls with nothing reading the resolved value, so the package's own front page taught nothing about which shape comes back — neither wrong nor useful. Each of the three now assigns its result and reads one member of it: `report.rows` / `report.fields[0].name` (`AnalyticsResult`), `cubes[0].name` (the bare `CubeMeta[]`), `run.status` (`AutomationResult`) — the members those contracts actually declare, read off the payload rather than off a `data` wrapper. - -No behaviour changes; this is the README that ships inside the package. The docs site's Client SDK and Data API pages take the same treatment in the same PR. diff --git a/.changeset/client-readme-retired-ai-methods.md b/.changeset/client-readme-retired-ai-methods.md deleted file mode 100644 index 0013cadf5e..0000000000 --- a/.changeset/client-readme-retired-ai-methods.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/client": patch ---- - -The README's namespace tour documents the `ai` surface that exists, not the three methods v17 removed. - -`client.ai.nlq` / `.suggest` / `.insights` were deleted in 17.0.0 (#3718) — and no server in any repo ever mounted `/api/v1/ai/{nlq,suggest,insights}`, so they 404ed for the whole life of the namespace. The README's "AI Services" example still showed all three. Because `files` ships `README.md` inside the tarball, that example is the package's npm front page: a TypeScript reader copying it gets TS2339 on three properties that are not on `client.ai`, and a JavaScript reader gets a runtime `TypeError`. - -The block now shows the surface the client really exposes — `ai.chat` (with a read of `answer.content` / `answer.usage`), `ai.complete`, `ai.models`, `ai.conversations.list`, `ai.agents.chat`, `ai.pendingActions.list` — every call type-checked against the package's own published `dist/index.d.ts`. It also names the condition a reader will otherwise hit unexplained: the AI routes are served by `service-ai` (a Cloud/EE package), and an environment without it answers 501 rather than 404, with the remedy discovery reports under `services.ai`. - -No behaviour changes. `patch` rather than no changeset because the README is a published file of this package, so correcting it changes what `@objectstack/client` ships; the docs site's Client SDK page already carried this correction and is untouched here. diff --git a/.changeset/colorfield-derive-describe.md b/.changeset/colorfield-derive-describe.md deleted file mode 100644 index 099854c9e0..0000000000 --- a/.changeset/colorfield-derive-describe.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`colorField` now documents what it means: a field to DERIVE a colour from, not a field holding one. - -`TimelineConfigSchema`, `CalendarConfigSchema` and `GanttConfigSchema` each declare a `colorField`, and all three `.describe()` strings said only that the field "determines"/"drives" the colour — `'Field to determine item color'`, `'Field whose value determines the event color'`, `'Field that drives the bar color'`. Read literally, that invites pointing the key at a field whose stored value *is* a colour, which is the one case the renderers need the least: the common author intent is `colorField: 'status'`, a select field whose options already carry the colours. - -The renderers resolve it as a derivation ladder (objectui#7243, shared as `createFieldColorResolver` in `@object-ui/core`): - -1. the option `color` the field declares for the record's stored value; -2. else the value itself, when it already is a colour literal (hex 3/6/8-digit, `rgb(...)`, `hsl(...)`); -3. else each renderer's own last rung — the gantt derives a semantic colour token, the calendar hashes onto its theme-aware palette, the timeline draws its default marker. - -The three strings now say that, each naming its own last rung. **Nothing in the accept set moves**: all three keys stay `z.string().optional()`, and a config pointing `colorField` at a plain hex field is still exactly as valid as before — that is rung 2. This is prose on a declared key, so the only regenerated follower is `content/docs/references/ui/view.mdx`. diff --git a/.changeset/compliance-families-retired.md b/.changeset/compliance-families-retired.md deleted file mode 100644 index 56bbfc3682..0000000000 --- a/.changeset/compliance-families-retired.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire the incident-response, training and change-management families whole — nineteen defs and every name they exported — and the `ESignatureConfig` deadline pair (#15513, #14477, ADR-0049) - - - -**BREAKING** — published exported symbols leave `@objectstack/spec/system`, and -two authorable keys leave `data/ESignatureConfig` — landing after the v17.0.0 -cut (the lockstep launch-window convention ships it as `minor`; the -registrations live under protocol major 18, where `os migrate meta` users will -look). Maintainer ruling 2026-09-05 on #15513 (decision batch #40, ruled A: -retire the three compliance-shaped families whole via `RETIRED_DEFS_BY_MAJOR`, -the `integration/ErrorMappingConfig` precedent; none of the three is -roadmapped) and, in the same stroke, the answer the 2026-09-02 ruling on #14477 -had held open (no roadmapped e-signature consumer ⇒ the pair retires with the -rest). ADR-0049 enforce-or-remove decides it — declared-but-unenforced surface -with zero measured readers comes off. - -## What leaves the public surface — the three families, whole - -Nineteen defs (the card counted fifteen; the manifest counts nineteen — the -ruling names the families, the number is the files' reading), forty-five -exported names, roughly a hundred declared keys, and the generated reference -pages `references/system/incident-response`, `training` and -`change-management`: - -| file | defs (`json-schema.manifest/system.json` spelling) | -|:--|:--| -| `system/incident-response.zod.ts` | `system/Incident`, `system/IncidentCategory`, `system/IncidentNotificationMatrix`, `system/IncidentNotificationRule`, `system/IncidentResponsePhase`, `system/IncidentResponsePolicy`, `system/IncidentSeverity`, `system/IncidentStatus` | -| `system/training.zod.ts` | `system/TrainingCategory`, `system/TrainingCompletionStatus`, `system/TrainingCourse`, `system/TrainingPlan`, `system/TrainingRecord` | -| `system/change-management.zod.ts` | `system/ChangeImpact`, `system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`, `system/ChangeType`, `system/RollbackPlan` | - -With them: every `*Schema` const, every `z.input` alias (`Incident`, -`IncidentResponsePolicy`, `TrainingCourse`, `ChangeRequest`, …) and the six -`*Parsed` aliases (`IncidentNotificationRuleParsed`, -`IncidentNotificationMatrixParsed`, `IncidentResponsePolicyParsed`, -`TrainingCourseParsed`, `TrainingPlanParsed`, `ChangeRequestParsed`). - -**Why.** The schemas were exported from `@objectstack/spec/system`, mounted by -no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 -liveness ledgers, and **read by nothing**: the reader census over every package -outside `packages/spec` (tests and changelogs excluded), over `examples/**` and -`skills/**`, and over objectui at the pinned sha (`a472b07`) returned zero hits -for every one of the forty-five names, with a lit control on the same pattern -(`ObjectSchema` / `FieldSchema`: 336, 200 and 342 hits per leg). Several keys -were boolean capability claims of exactly the shape ADR-0049 names — -`IncidentNotificationRule.notifyRegulators`, -`IncidentResponsePolicy.requirePostIncidentReview`, `TrainingCourse.mandatory`, -`TrainingPlan.trackCompletion` / `sendReminders`, -`ChangeRequest.approval.required`, -`ChangeRequest.securityImpact.requiresSecurityApproval` — so an author writing -`notifyRegulators: true` held a compliance promise the platform never kept, -with no error and no feedback, and the reference docs advertised a compliance -subsystem that does not exist. Tagging the families -`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is -a human-only signal, and an AI generating from the schema still writes the key -and believes it. - -**What happened to the fourteen #14477 deadline-key tombstones** (PR #15514, -merged 2026-09-04): they leave with their defs' source. Their fourteen -`RETIRED_KEYS_BY_MAJOR[18]` entries and three D3 entries stay as history — gate -(b2) of `build-schemas.ts` accepts an entry naming a key the build no longer -emits, and the 17→18 upgrade guide still owes the reader those prescriptions. -`deadline-keys-retirement.test.ts`, whose every pin needed the schemas to exist, -is replaced by `compliance-families-retirement.test.ts`. - -## What is refused — the `ESignatureConfig` pair - -Authoring `expirationDays` or `reminderDays` on an `ESignatureConfig`, with any -value, on the base schema and through `Document.eSignature`. The schema is not -`.strict()`, so each key is a `retiredKey()` tombstone rather than a bare -deletion (a deletion would have stripped it in silence): authoring it is a `tsc` -error (`never`) and a parse error carrying the prescription (`invalid_type` at -the path of the key). Both carried defaults (30 days, 7 days) that were -materialized into every parsed configuration without ever being consulted; -parsed configurations no longer carry them. `provider`, `enabled` and `signers` -stay, byte-identical. Census for the pair: zero hits for `expirationDays`, -`reminderDays`, `eSignature` and the `ESignatureConfig` names on all three legs, -control lit inside `packages/spec` (`document.zod.ts` 9, `document.test.ts` 24). - -**Unmeasured, verbatim:** `cloud` and real customer configurations are -UNMEASURED for both the families and the pair — this census covers this repo -and objectui at the pin. - -## FROM → TO - -```ts -// before — imported and parsed green; no engine ever read a single key -import { IncidentResponsePolicySchema, type IncidentResponsePolicy } from '@objectstack/spec/system'; -const policy: IncidentResponsePolicy = { - notificationMatrix: { rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], notifyRegulators: true }] }, - defaultResponseTeam: 'security_team', - requirePostIncidentReview: true, -}; -IncidentResponsePolicySchema.parse(policy); - -const signing: ESignatureConfig = { - provider: 'docusign', - signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], - expirationDays: 30, - reminderDays: 7, -}; - -// after — the import is TS2305 and there is no replacement to point at, because -// no incident-response, training-management or change-management engine exists. -// A compliance record the organisation keeps is ordinary object data, declared -// as an object with its own fields and enforced by the object engine; an -// approval that must actually gate something is a flow (ADR-0018) with an -// approval node. -// -// The e-signature pair: delete the keys. `ESignatureConfig` itself stays. -const signing: ESignatureConfig = { - provider: 'docusign', - signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], -}; -``` - -One-line fix: delete the import (families) or the key (pair) wherever it is -authored. There is no `os migrate meta` edit list — none of the schemas is a -stack collection member and `document` is no metadata type, so the conversion -chain has no seam to walk (the `MetadataPluginConfig.additionalTypes` -precedent); the tombstone prescriptions, the `tsc` refusals and the protocol-18 -upgrade guide are the channels. - -The retirement kit: - -- the three schema files and their tests deleted whole; the survivor notes in - `packages/spec/src/system/index.ts` record what each module declared and why - nothing ever read it -- ADR-0087 registration: nineteen `RETIRED_DEFS_BY_MAJOR[18]` entries - (`entries/retired-defs/18.system__*.ts`) and three D3 semantic entries, one - per family; for the pair, `data/ESignatureConfig:expirationDays` and - `data/ESignatureConfig:reminderDays` in `RETIRED_KEYS_BY_MAJOR[18]` plus the - D3 entry `esignature-config-deadline-keys-retired`; the step-18 `rationale` - extended -- no liveness-ledger row: none of the families and neither `document` nor - `ESignatureConfig` is an enrolled ledger type, so there is no row to keep or - drop -- pin tests: `compliance-families-retirement.test.ts` (zero holders of the - forty-five names on every public entry via `export-origins/`, the deletion - probe, the in-package importer walk, the runtime namespace, the shards' - absence, the ADR-0087 registration, the #15514 history kept, and a - tree-scoped absence leg whose walk radius is DECLARED in - `scripts/cross-package-test-inputs.mjs` / `turbo.json` — the playbook rule - #15566 added after PR #15514); `esignature-deadline-keys-retirement.test.ts` - (refusal pins asserting issue path, code and prescription on the base schema - and through `Document.eSignature`; the tsc `never` channel; no-materialize - pins for the two former defaults; the ADR-0087 registration); the thirteen - isomorphism pins the three modules held leave `type-alias-convention.pin.test.ts` -- generated baselines and docs follow the schema: `json-schema.manifest/` - loses nineteen keys (the manifest-deletion gate adjudicates whole-def - removals against the merge base), `api-surface/`, `declaration-map/`, - `export-origins/`, `authorable-surface/` and `authorable-defaults/` lose the - families' rows, `authorable-surface/data.json` gains two `[RETIRED]` rows and - `authorable-defaults/data.json` loses two, the three system reference pages - are removed and `references/system/index.mdx`, `references/index.mdx` and - `references/data/document.mdx` regenerated, `spec-changes.json` and the - upgrade guide carry the four new registrations at the 18 cut -- hand-written docs: the `Change Management` row leaves - `getting-started/quick-reference.mdx` -- zero authored occurrences in this repo's examples, skills and hand-written - docs beyond that row, and zero hits in objectui at `a472b07`, so no sibling - change and no pin bump ride along diff --git a/.changeset/compose-merge-refuses-object-collections.md b/.changeset/compose-merge-refuses-object-collections.md deleted file mode 100644 index 278a7b137d..0000000000 --- a/.changeset/compose-merge-refuses-object-collections.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848) - - - -**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` -— shipped as `minor` under the repo's launch-window convention for breaking -changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38 -item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge -instead of dropping it. - -**What changed.** `'merge'` was implemented as -`{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`: -`fields` was the only key merged, and every other key the later object carried -— `actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier -package's value wholesale, with nothing at compose, build or boot saying so. -Two packages each embedding an action on one shared object composed to the -later package's array alone; the earlier package's action was gone. - -Now, when both objects declare an object-level **collection** other than -`fields` with different values, `composeStacks` throws — the refusal shape -`'error'` uses — naming the object, the colliding collection and both stacks -by manifest id: - -``` -composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). -objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote. -Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. -``` - -The refusal set is **derived from `ObjectSchema`'s shape** — every key whose -declared type is an array or a record (through optional/default wrappers and -into a union's members), except `fields` — not hand-listed, so a collection key -added to the object schema joins the refusal without an edit to the composer. -Today that set is `actions`, `activityMilestones`, `fieldGroups`, -`highlightFields`, `indexes`, `listViews`, `requiredPermissions`, -`searchableFields`, `validations`. - -**What did not change.** - -- `fields` keeps its documented shallow merge (later fields win, earlier - fields kept). -- **Identical** declarations on both sides pass through and are carried once - — the same reading `composeStacks` already gives identical top-level values - — so two built stacks that each bind one standalone action to the same - object (identical copies) still reach the cross-stack action-key check - (#14662) and are refused there, by name, as before. -- A scalar or fixed-shape config object the later object declares (`label`, - `sharingModel`, `enable`, `access`, …) still replaces the earlier one: the - ruling narrows collections only, and the docblock now says so. -- The default `'error'` and `'override'` are untouched, message for message. -- An explicit `undefined` on the later object is read as no declaration — it - neither counts as a differing value nor erases what the earlier stack - declared (the bare spread used to let it). - -**Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test -call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at -all — every real caller takes the default `'error'`. An external author who -opted into `'merge'` and relied on the later package's collection winning -silently now gets the refusal above; the fix is the one it names. - -The `ConflictStrategySchema` docblock for `'merge'` states the rule. diff --git a/.changeset/config-miss-refusal-to-stderr.md b/.changeset/config-miss-refusal-to-stderr.md deleted file mode 100644 index ea723170f8..0000000000 --- a/.changeset/config-miss-refusal-to-stderr.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os validate|info|diff|lint|compile|build|verify|migrate meta|i18n check|i18n extract --json` no longer print human text on stdout when the config file is missing. - -`resolveConfigPath()` emitted both of its refusals — the explicit-path miss and the auto-detect miss — through `printError` and `console.log`, **both of which write to stdout**, and then called `process.exit(1)` directly. Ten published `--json` faces reach that helper, so a missing config file answered them with exit 1, an unparseable stdout and an **empty stderr**: 206 bytes of prose on the one stream `--json` reserves for the machine. And because the exit was called rather than thrown, every command's catch-all `--json` error exit — all of which sit downstream of a throw — never ran. - -The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. Nothing else moves: - -- **the exit code is still 1**, so a consumer branching on exit status sees no change at all; -- **the wording is unchanged**, hints included, so a human reading a terminal sees the same three lines; -- **nothing is accepted or rejected differently** — no config that loaded before fails now, and none that failed now loads. - -⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* when the config file is missing is an envelope question that touches ten published faces at once, and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. - -A new pin (`config-miss-stdout-purity.e2e.test.ts`) drives all ten faces on both branches of the helper. The existing purity pin could not: it discovers its family as the commands that call `bootSchemaStack`, and these fail before any kernel boots. diff --git a/.changeset/connector-error-mapping-retired.md b/.changeset/connector-error-mapping-retired.md deleted file mode 100644 index 3893d2d28a..0000000000 --- a/.changeset/connector-error-mapping-retired.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `connector.errorMapping` — eleven authorable keys nothing ever read, one of them spelled like the live `userMessage` channel (#14676, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). -Triage ruling 2026-09-02 on the census card: ADR-0049 enforce-or-remove decides -it — declared-but-unenforced authorable surface with zero measured pull for a -reader comes off. - -`ConnectorSchema.errorMapping` carried `ErrorMappingConfig` (`rules`, -`defaultCategory`, `unmappedBehavior`, `logUnmapped`) and its -`ErrorMappingRule[]` (`sourceCode`, `sourceMessage`, `targetCode`, -`targetCategory`, `severity`, `retryable`, `userMessage`) — eleven keys on the -published authorable surface that **nothing read**: measured on `origin/main`, -the only reference outside the declaring file and its unit test was a -type-identity pin. No provider, dispatcher or materializer ever mapped an -external error through the rules, so `unmappedBehavior` configured nothing and -a rule's `userMessage` was never shown to anyone. That spelling is what made -this worse than ordinary dead surface: it is the name of the **live** -API-error channel (`ApiError.userMessage`, the user-facing refusal text a -thrown HTTP error declares), so an author who had read that documentation and -wrote a connector rule reasonably believed they were marking a refusal for an -end user — and the failure was silent in both directions (it validated, it -published, no message was ever shown). Removal resolves the collision by -deletion; the live channel is untouched. - -**What is refused:** authoring `errorMapping` on a connector, with any value. -`ConnectorSchema` is a non-strict `z.object`, so the key is a `retiredKey()` -tombstone rather than a bare deletion (a deletion would have stripped it in -silence): authoring it is a `tsc` error (`never`) and a parse error carrying -the prescription, on the base schema and — through -`DeclarativeConnectorEntrySchema`, which `superRefine`s the same shape — on -`stack.connectors[]` and the `PUT /api/v1/meta/connector/:name` door. - -**What leaves the public surface:** `ErrorMappingConfigSchema` / -`ErrorMappingConfig` / `ErrorMappingConfigParsed`, `ErrorMappingRuleSchema` / -`ErrorMappingRule`, and `ConnectorErrorCategorySchema` / `ConnectorErrorCategory` -(the enum's only consumers were the two removed shapes; an exported value -schema with no consumer reads as a capability). `api/ErrorCategory` — the -HTTP-response vocabulary — is unaffected. - -**What stays, byte-identical:** every other connector key (`health`, `retry`, -`webhooks`, `fieldMappings`, `syncConfig`, `actions`, `triggers`, `provider`, -`providerConfig`, `auth`, …) with its default and its readers. - -## FROM → TO - -```ts -// before — parsed green; nothing ever read the block, no message was ever shown -defineStack({ - connectors: [{ - name: 'payments_api', - label: 'Payments API', - type: 'api', - errorMapping: { - rules: [{ - sourceCode: 429, - targetCode: 'RATE_LIMITED', - targetCategory: 'rate_limit', - severity: 'medium', - retryable: true, - userMessage: 'The payment provider is busy; try again shortly.', - }], - unmappedBehavior: 'generic_error', - }, - }], -}); - -// after — delete the key; there is no replacement because no error-mapping -// engine exists: a connector's failures reach callers as the provider's own -// errors (ADR-0097). A user-facing refusal text is the API error envelope's -// `userMessage`, declared by the code that throws — not connector metadata. -defineStack({ - connectors: [{ name: 'payments_api', label: 'Payments API', type: 'api' }], -}); -``` - -One-line fix: delete the `errorMapping` block; `os migrate meta --from 17` -lists the mechanical edits for existing sources. - -The retirement kit: - -- `retiredKey()` tombstone on `ConnectorSchema.errorMapping` - (`packages/spec/src/integration/connector.zod.ts`; the section comment - records what the shape was), inherited by `DeclarativeConnectorEntrySchema` -- ADR-0087 registration: `integration/Connector:errorMapping` and - `integration/DeclarativeConnectorEntry:errorMapping` in - `RETIRED_KEYS_BY_MAJOR[18]`; `integration/ErrorMappingConfig`, - `integration/ErrorMappingRule`, `integration/ConnectorErrorCategory` in - `RETIRED_DEFS_BY_MAJOR[18]`; the D2 conversion - `connector-error-mapping-removed` (protocol 18) wired into the step-18 chain - — a pure lossless strip of the block from every `connectors[]` entry, one - notice per connector (the eleven nested keys leave with the block) -- no liveness-ledger row: `connector` is not an enrolled ledger type, so - there is no row to keep or drop -- pin tests (`connector.test.ts`): refusal pins asserting the issue path, - code and prescription on the base schema, the declarative entry, and the - `stack.connectors[]` authoring path; the tsc `never` channel; a - no-materialize pin; the conversion's strip and notice; zero holders of the - seven retired names on every public entry; the ADR-0087 registration -- generated baselines/docs follow the schema (`authorable-surface/`, - `authorable-defaults/`, `api-surface/`, `json-schema.manifest/`, - `declaration-map/`, `export-origins/`, spec-changes, upgrade guide, - reference docs) -- zero authored occurrences in this repo's examples, skills and docs, and - zero hits in objectui at `0d8fd7c`, so no in-repo source changes ride along diff --git a/.changeset/console-a472b07167a3.md b/.changeset/console-a472b07167a3.md deleted file mode 100644 index 1cda964017..0000000000 --- a/.changeset/console-a472b07167a3.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `a472b07167a3`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 15 releasing of 18 changesets added across 29 non-merge commits; omitted: 3 release-nothing changesets, 11 commits carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — Converge the lookup/user widget metadata on the spec's camelCase — one concept, one spelling (objectui#7155, maintainer ruling A′ of 2026-09-03, director decision batch #19). (objectui `351eb3181`) -- **minor** — **BREAKING** — One authority for `KanbanSchema` / `KanbanColumn` / `KanbanCard`: the bare names now belong to `@object-ui/plugin-kanban` (objectui#6172, closing the cross-package half of objectu… (objectui `2c71482ea`) -- **minor** — Retire `ComponentInput.inputType` — the fifth and last key objectui#5905 named (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-31, option B). (objectui `1ec291c0d`) -- **minor** — `@object-ui/core` publishes `resolveRecordSourceObjectName`, the ONE reader for "which object is this block bound to" (objectui#7627). (objectui `b041b9c0c`) -- **minor** — **Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the dashboard-root `title` member (objectui#7623). (objectui `5d0876c5c`) -- **minor** — **BREAKING** — BREAKING (`@object-ui/components`): the chart primitives — `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle` and the `Char… (objectui `7bf244bea`) -- **minor** — ListView: fold `data={{ provider: 'object', object }}` onto `objectName`, and read the author's view kind from `specType` / `type` (objectui#7477 — step 6 of #2890, released by th… (objectui `00d2fa682`) -- **minor** — Retire the dashboard-**root** `title` read across all five surfaces (objectui#7509, maintainer ruling 2026-09-04, decision batch #29, option C, under ADR-0049). (objectui `1cca678ba`) -- **minor** — **BREAKING** — Re-home the breakpoint layout vocabulary and delete the two dead responsive implementations (objectui#7580, maintainer ruling 2026-09-04, option A). (objectui `e62c44e7e`) -- **minor** — `@object-ui/types/zod`: the zod const `StylePropsSchema` is renamed to `ClassNameStylePropsSchema` (objectui#5928). **The old name is gone** — there is no deprecated alias and no… (objectui `24e027e93`) -- **patch** — Fix `extractToc` eating the underscores out of a `SCREAMING_SNAKE` heading, so its `#id` links resolve to the heading they name again (objectui#7667). (objectui `a472b0716`) -- **patch** — Remove `src/ui/toast.tsx`, an unreferenced primitive, and the dependency only it imported (objectui `2f61238b9`) -- **patch** — Fix `extractToc` deleting tag-shaped text that lives INSIDE an inline code span, so its `#id` links resolve to the heading they name again (objectui#7658). (objectui `90c6d090d`) -- **patch** — A record-page URL now names the object the clicked rows actually came from, in `ObjectTree` and `ObjectCalendar` (objectui#7638). (objectui `2ce2612df`) -- **patch** — fix(app-shell): the object-field options editor no longer drops `default` and `visibleWhen` on save (objectui `97c3e1972`) - -⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 11 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644) (#7657) (objectui `f7cf7e8a9`) -- _(no changeset)_ docs(plugin-chatbot): document chatbot-floating's seven declared inputs keys (objectui#7594) (#7656) (objectui `8e501cb97`) -- _(no changeset)_ docs(agents): record the never-approve seat rule beside the governed never-list (#7630) (objectui `2e99852ca`) -- _(no changeset)_ refactor(examples): drop the inert root `title` from six catalog dashboards (#7634) (objectui `46cde8264`) -- _(no changeset)_ docs(check-skill-examples): drop the stale zero-jsonc-fences claim (#7631) (objectui `0b24d7f85`) -- _(no changeset)_ docs(governed-guard): replace the retired sha pin with the ruled approval-record predicate (#7616) (objectui `11edab88f`) -- _(no changeset)_ docs(skills): split multi-document JSON fences, drop the `...` elisions, mark every parsing fence (#7608) (objectui `89d6adf37`) -- _(no changeset)_ fix(scripts): judge spec citations at member granularity, and stop the header teaching a retired filter (objectui#7513) (#7617) (objectui `d28d87bf4`) -- _(no changeset)_ fix(governed-guard): an authorised approval record satisfies the queue leg on any commit (#7606) (objectui `0d8fd7ce3`) -- _(no changeset)_ chore(deps): Bump fumadocs-core from 16.14.4 to 16.15.4 (#7059) (objectui `1bae75bb8`) -- _(no changeset)_ docs(claude-md): collapse the two AGENTS.md excerpts to rule + hook + pointer (#7600) (objectui `c70ebaaeb`) - - - -objectui range: `00d3f09c500c...a472b07167a3` diff --git a/.changeset/contained-failure-visibility.md b/.changeset/contained-failure-visibility.md deleted file mode 100644 index fd9080a7bf..0000000000 --- a/.changeset/contained-failure-visibility.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -A contained per-iteration failure is now visible at run level, attributed to its iteration, and bound to its row. - -`loop { body: [ try_catch { try, catch } ] }` is the containment spelling for a per-iteration failure that must not end the sweep (there is deliberately no `loop.config.onIterationError` key). Containment already worked — the failure was caught, the loop went on and the run completed — but nothing said what it had contained: a sweep that lost two rows out of five reported `status=completed selected=5 acted=9 skipped=0` and was indistinguishable from one that lost none. The failure was in the step log and in `nodes[].failures`; no run-level number carried it, the failing step named no row, and `$error` bound no row identity. - -Four changes populate the contract `@objectstack/spec` already declares: - -- **`FlowRunSummary.failed`** — `summarizeRun` now folds `failed = Σ nodes[].failures` over the per-node array it publishes, so the run-level count can never disagree with the breakdown it summarizes. It counts every node execution that failed, contained or fatal; on a run that completed, all of them were contained. -- **`failed=N` on the run summary line** — `formatRunSummaryLine` prints the token whenever the count is present, `failed=0` included. That is the opposite of the `unmeasured` rule beside it and deliberate: `unmeasured` qualifies `acted`, while `failed` answers a question a completed run's line otherwise cannot be asked at all. Read `failed=0` precisely: **no node execution of this run failed**. It is the node fold and only that, so a `subflow` child's own contained failures stay on the child's summary rather than rolling up the way `acted` does — see #15617, where the declaration's two paragraphs are being reconciled. -- **Iteration through `try_catch`** — a step that ran in a `try` or `catch` region inside a loop body now carries the enclosing loop's `iteration`, with `regionKind` still `try` / `catch`. The step says which region ran it *and* which row it ran for. `parallel` branch tagging is unchanged. -- **`$error` binds the row** — the value bound to `errorVariable` (default `$error`) is the declared `TryCatchErrorValue`: `nodeId` and `message` as before, plus `iteration` and the loop's current `item` when the failure happened inside a loop body. A `subflow` / `map` child run has its own variable scope and therefore binds neither, so a parent's row identity never leaks into a child's `$error`. - -**`failed` absent means "not tracked", never `0`.** Runs recorded before this change keep it absent — no migration and no default, the same convention `unmeasured` carries. Defaulting it to zero would tell an operator "nothing failed" about a run nobody measured. Absent, the summary line prints no `failed=` token at all; present-and-zero prints `failed=0`. The count rides in the persisted `summary_json`, including on a summary compacted past the size cap, where the per-node `failures` it folds are exactly what gets dropped. diff --git a/.changeset/core-private-keys-pin-extension-boundary.md b/.changeset/core-private-keys-pin-extension-boundary.md deleted file mode 100644 index 1f35a21fb9..0000000000 --- a/.changeset/core-private-keys-pin-extension-boundary.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): narrow the operation-private-keys pin's scanner to `.ts`, so it judges exactly the population turbo re-runs it for (#15090) - -`packages/core/src/security/operation-private-keys.pin.test.ts` filtered its -candidate set with `/\.tsx?$/` — `.ts` **and** `.tsx` — while this package's -declared radius in the cross-package declaration table is a `packages/**` -subtree glob ending in `.ts`. So the pin judged a population **strictly wider** -than the one either scoping layer of `check:cross-package-test-inputs` knows -about: Layer A never unions this package into the test shard when a `.tsx` file -changes, and Layer B never moves the `test` task's cache hash for one. A `.tsx` -file under `packages/` declaring its own `OPERATION_PRIVATE_KEY_PREFIX` or -`withoutOperationPrivateKeys` was therefore scanned by the pin and invisible to -CI's scoping — landing on `main` with every PR green and then reddening whichever -unrelated PR next touched a `.ts` file. That is the #7802 shape the declaration -table exists to close, one extension wide. - -Repaired by narrowing the **scanner**, not by widening the **glob** — and that -asymmetry is measured rather than assumed. On `b548e438d`, adding a `.tsx` glob -to this package's roster entry and re-deriving `check:cross-package-test-inputs`' -watch hints flips the dispatch-gates self-test case *"nor a .tsx test file inside -it"* from true to false, with the added glob itself as the covering hint. That -case is a live specimen for "a test class the hint route cannot reach", so the -red is real and re-pointing it is a decision in another lane, not a fixup. - -What the boundary costs, measured on the pin's own surface (tracked **plus** -untracked, ignored paths excluded) at `b548e438d`: **5408** `.ts` files scanned, -8 of them mentioning a guarded symbol; **8** `.tsx` files excluded, **0** of them -mentioning either symbol. The loss is empty today — and that reading is no longer -transcribed and trusted. A new case re-measures it on every run: it asserts the -excluded `.tsx` population is non-empty (so the boundary is an exclusion and not -an empty tree describing itself), that the filter really drops those files, and -that none of them declares either symbol. Ablation, with the restore proven by -blob hash rather than by exit code: re-widening the scanner reddens it while the -offender assertion stays green — which is precisely the failure mode, since a -wider scanner reads as coverage CI never runs — and planting a `.tsx` -redeclaration reddens it with a message that says the choice is a second-gate -trade, not a one-line widening. - -The correspondence between scanner and glob is now stated at **both** ends: the -pin's header and the declaration table's entry for this package. No published -surface moves — the only source file edited is a test. diff --git a/.changeset/core-time-zone-domain-repoint.md b/.changeset/core-time-zone-domain-repoint.md deleted file mode 100644 index ccfbd34775..0000000000 --- a/.changeset/core-time-zone-domain-repoint.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/core': patch ---- - -refactor(core): the authz context's time-zone probe is now the shared value-domain predicate, not a third copy of it - -`resolve-authz-context.ts` carried a module-private `isValidTimeZone` — the -`Intl.DateTimeFormat` probe, re-stated. It was the third copy of one -definition, alongside `@objectstack/spec/shared`'s `isValueDomainMember` and -`service-settings`' own re-statement. `coerceTimeZone` now calls -`isValueDomainMember('iana_time_zone', …)` and the copy is gone. - -**No behavioural change, measured rather than asserted.** The two predicates -were run over a shared 4,058-input corpus — the zones -`Intl.supportedValuesOf('timeZone')` omits (`UTC`, `Asia/Kolkata`, -`Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `US/Eastern`, `GMT`), every member of that -enumeration plus its case- and space-padded variants, refusals, `Etc/` and -offset spellings, legacy aliases, and fuzz — with **zero disagreements**, and -the same zero at the `coerceTimeZone` level. The call site's own -pre-processing (trim, stringify a non-string, refuse blank) is unchanged. - -What this buys is drift resistance, not a fix: core's time-zone acceptance now -sits under the shared pins, so a future "modernisation" to -`Intl.supportedValuesOf('timeZone')` — which would silently narrow what the -authz context accepts, since that enumeration omits this platform's own -default `UTC` — turns a test red instead of shipping. diff --git a/.changeset/cron-dialect-row-names-croner.md b/.changeset/cron-dialect-row-names-croner.md deleted file mode 100644 index 722418f529..0000000000 --- a/.changeset/cron-dialect-row-names-croner.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The Expression Protocol dialect table no longer names `cron-parser` as the `cron` engine. That package is not a dependency of any ObjectStack package; the row shipped to authors through the generated reference page (`content/docs/references/shared/expression.mdx`) and pointed them at the wrong library for field counts, alias vocabulary and second-field semantics. - -The row now says what the code does: no cron syntax is judged at parse time; `croner` evaluates a cron expression only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → `CronJobAdapter`, where an invalid pattern is refused); every other cron-typed slot is parsed and reaches no engine; and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. Documentation only — no schema, accept set or behaviour changes. diff --git a/.changeset/dashboard-gap-author-vocabulary.md b/.changeset/dashboard-gap-author-vocabulary.md deleted file mode 100644 index 2b8464be53..0000000000 --- a/.changeset/dashboard-gap-author-vocabulary.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/platform-objects": patch ---- - -fix(spec): the dashboard `gap` field no longer describes itself to app authors in Tailwind vocabulary - -`ui/dashboard`'s `gap` key told app authors its value in the vocabulary of a CSS -library they never chose and cannot act on. **Two** independent producer strings -carried that wording, and they feed two independent customer-facing surfaces: - -- `dashboardForm`'s `helpText` — `Grid gap (Tailwind units)` — rendered verbatim in - the Studio property panel, which is spec-driven and feeds this form straight into - the generic form renderer. -- `DashboardSchema.gap`'s `.describe()` — `Grid gap in Tailwind spacing units` — - rendered as this field's row in the published reference page - `content/docs/references/ui/dashboard.mdx`. The reference corpus renders - `.describe()`, never `helpText`. - -Both now read **Space between widgets, in steps of 0.25rem (4 = 1rem)**: what the -author decides, plus the magnitude, stated in a CSS unit instead of a framework's -scale. The magnitude had to survive the rewrite rather than be dropped with the -framework name — the number is a spacing step, so `4` means `1rem` and not `4px`, -and an author who lost that would come away knowing less than before. - -The step size is stated as measured rather than inferred: the dashboard renderer -sets the grid gap as an inline style computed from this key, so every accepted -value is linear and one step is exactly `0.25rem`. "Tailwind units" was doubly -wrong — it named an implementation dependency, and it named one the consumer of -this key does not have. - -**No schema change.** `gap` stays `z.number().int().min(0).optional()` and accepts -exactly what it accepted before; nothing is added to or removed from any public -surface. `columns` is deliberately untouched on both of its producer lines — -`12` is an author-visible fact about the grid being laid out, not a framework -detail — and this is one field's two strings, not a sweep for framework words. - -The `en` metadata-forms translation bundle is a mechanical copy of the form source, -so it is regenerated to match. Translated locales are not touched: regeneration -fills gaps only and never overwrites an existing leaf. diff --git a/.changeset/data-driver-aggregate-declared.md b/.changeset/data-driver-aggregate-declared.md deleted file mode 100644 index 1563cf344c..0000000000 --- a/.changeset/data-driver-aggregate-declared.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`IDataDriver` now declares `aggregate?` — the one engine-reached driver verb that had no signature to match against. - -The engine has always dispatched native aggregation by presence (`typeof driver.aggregate === 'function'`) and called `driver.aggregate(object, query, options)`, but the interface never spelled the member, so a custom driver's `aggregate` was checked in neither direction: swapped arguments or a non-row result compiled clean and surfaced only after the engine's `having` filter silently matched nothing. The member is declared optional, matching the presence test — a driver without native aggregation omits it and stays conformant, served by the `find()` + in-memory fallback. - -Additive: every in-repo driver already satisfies the declared signature (`(object: string, query: DriverQuery, options?: DriverOptions) => Promise[]>`); a wider parameter union or a looser return type stays assignable. What is newly refused is a wrong argument order or a non-array result. No `DriverCapabilities` bit is added — presence remains the capability test, as `data/driver.zod.ts` rules. diff --git a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md deleted file mode 100644 index c66ebc58f2..0000000000 --- a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) - - - -**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped -as `minor` under the repo's launch-window convention for breaking changes; the -hand-migration prescriptions are registered under protocol major 18. Maintainer -ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in -the key NAME, never only in its `.describe()` prose, and grandfathers no existing -offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 -(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 -(#15679) cleared `system/`. This card clears the remainder, and is the first -where the gate itself reads **`zero offenders`** and exits `0`. - -⚠️ That is green **for the gate's currently declared population** -(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has -already measured an offender outside this subtree, so the gate is expected to go -red again by design. This changeset does not claim #14478 is finished. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | -| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | -| `ConnectorTrigger.interval` | `intervalSeconds` | seconds | -| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | -| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | -| `TursoConfig.timeout` | `timeoutMs` | milliseconds | -| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | -| `ConversationAnalytics.duration` | `durationSeconds` | seconds | - -**Every value is unchanged** — only key names move. The two keys that carried a -default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to -60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare -none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything -under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and -`TursoConfig.timeoutMs` still refuse a zero or negative integer, and -`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old -spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site -(input type `never`) and fails the parse with the rename prescription rather than -a bare unrecognized-key error. - -`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` -— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming -the tombstone would have prescribed a key the shape refuses, which is the one -failure this rename could have introduced silently; a pin asserts all three. - -## ⚠️ `dashboard.refreshInterval` crosses a repository boundary - -This is the only rename in the whole stack whose consumer is in **another -repository**, so its reader could not move in this PR the way every other reader -in this card did. objectui's dashboard renderer reads the key, multiplies by -1000 to drive a `setInterval`, and republishes it as an authoring input the -console offers. Those sites move in a follow-up objectui card, sequenced behind -a release that actually ships this rename. - -Until that lands the renderer sees an absent key and simply does not start its -refresh timer — a dashboard still renders, and still refreshes when the user -asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards -and `os migrate meta` correct in the meantime. - -## ⚠️ An eighth key moves that the gate did not list - -`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` -named no unit at all, and the predicate judges prose against name. - -It moves anyway because it is not a second key. `persistence: { type: 'auto' }` -resolves to the same Node.js file adapter as `type: 'file'`, and this value is -forwarded to the same `FileSystemPersistenceAdapter` field, in the same -milliseconds, under the same `min(100)` bound. Renaming one arm and not the other -would have left one value with two spellings across sibling arms of one union, -and the driver reading both — the consumer-side dialect Prime Directive #12 -forbids. Its describe now names the unit too, and a pin asserts the refusal on -the arm the gate never listed, so a later reader cannot "restore" the bare -spelling as an over-application of the rule. - -## Dispositions — four D2 conversions, two semantic entries - -Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and -unlike card 4/6 this card's answer is split. - -**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` -are each a stack collection whose members are stored whole as `sys_metadata` -rows, so the conversion chain has a seam that sees them: -`dashboard-refresh-interval-to-refresh-interval-seconds`, -`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one -pass, emitting separately), -`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and -`turso-config-timeout-to-timeout-ms`. The two datasource conversions are -driver-aware for the reason `datasource-config-driver-key-aliases` records: a -bare `config.timeout` under another driver is that driver's own key and must not -be touched. - -**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime -and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument -reached only through `AggregationPipeline.options`. Neither is a stack collection -member or a stored row, so the chain has no seam — the disposition every -runtime-emitted measurement in this stack has taken. - -All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. - -## A retirement tombstone is no longer read as a secret - -`refusedCredentialKeys` derives a driver's refused inline credentials by finding -`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a -`z.never()`, and until this card no driver contract carried one — so "never ⇒ -credential" held by accident of population rather than by construction. The first -tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a -millisecond budget was a secret: it was redacted off the datasource read path and -dragged a non-credential name into the fallback list every unrecognised driver is -scrubbed by. - -The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` -itself stamps. The exclusion is deliberately **negative** — skip declared -tombstones — rather than positive (keep only keys marked `format: 'password'`), -even though every credential slot in every builtin contract does carry that -marker today: under-redacting is the dangerous direction, so a future credential -key whose author forgets the marker is still scrubbed, and only a key that has -explicitly declared itself retired may drop out. Both directions are pinned. - -## Keys deliberately left alone - -`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` -already carried their unit — they are the same-shape neighbours that made the -bare `timeout` and `monitoringWindow` collisions visible, and pins assert they -did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every -number on `ConversationAnalytics` other than the duration is a count of messages, -tokens or events: a count has no unit to carry. The turso schema shipped by -`@objectstack/driver-turso` is a separate declaration outside this gate's -declared population and is not touched here; card 6/6 owns it, so the two -declarations disagree by design until that lands. diff --git a/.changeset/datasource-admin-tenancy-posture.md b/.changeset/datasource-admin-tenancy-posture.md deleted file mode 100644 index 455ff108e2..0000000000 --- a/.changeset/datasource-admin-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -The datasource admin routes derive the tenancy posture before resolving the caller - -`requireDatasourceAdmin` resolved the request with `resolveAuthzContext({ ql, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran on this family, and an API key stamped with an organization its owner had left was admitted; the routes then gated it on `authz.systemPermissions` alone. Because this family gates on system capabilities rather than on organization-scoped rows, the consequence was an admitted principal rather than a cross-organization row read. - -The posture is now read off the kernel's `tenancy` service and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Patch rather than minor: no accept set widens, and a declared guard returns to enforced. diff --git a/.changeset/decision-predicate-envelope-refused.md b/.changeset/decision-predicate-envelope-refused.md deleted file mode 100644 index 2d29f4e39b..0000000000 --- a/.changeset/decision-predicate-envelope-refused.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. - -A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. - -- `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. -- `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. - -**String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. - -An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. diff --git a/.changeset/delete-data-request-schema-provenance.md b/.changeset/delete-data-request-schema-provenance.md deleted file mode 100644 index 7c1bccf9d5..0000000000 --- a/.changeset/delete-data-request-schema-provenance.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Record, on `DeleteDataRequestSchema` itself, what it is for and why the DELETE data door carries no `requestSchema` for it. - -The schema is the request contract of `DataProtocol.deleteData()`, consumed statically through the `DeleteDataRequest` type alias and parsed at runtime nowhere — a grep that finds "exported, documented, zero `safeParse` call sites" is reading the wrong surface, and had already filed it once as a gap. Its docblock now says so; records that the absence of a `requestSchema` on `DELETE /api/v1/data/:object/:id` is a pinned decision (#3899 — the catalog entry states it in place of the key, and `plugin-rest-api.schema-refs.test.ts` goes red if one is added, because the route reads no body); and points at the compile-time check (#15866) under which a field added to the schema as required reddens the door at build instead of being silently unsent. - -Documentation only: no shape, `.describe()` text, or export changes. `@objectstack/spec` ships the new text in its published type declarations and in the source file it publishes directly via its `src/**/*.zod.ts` entry. diff --git a/.changeset/diagnostics-untyped-sweep-organization-forwarding.md b/.changeset/diagnostics-untyped-sweep-organization-forwarding.md deleted file mode 100644 index 259062b790..0000000000 --- a/.changeset/diagnostics-untyped-sweep-organization-forwarding.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -An organization-scoped caller's own items now appear in the untyped metadata diagnostics sweep. - -`GET /api/v1/meta/diagnostics` has two arms. The `?type=` arm has stated the caller's organization since #13753; the untyped whole-registry sweep passed none, so the Studio governance summary reported clean tiles over a partition it never read — undercounting relative to the per-type drill-down screen you reach by clicking into it. A summary whose whole job is surfacing problems, and which structurally cannot see a class of them while its own drill-down can, issues a false all-clear. The untyped arm now forwards the caller's organization, so items that organization authored on the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`) are counted in `stats`, `total` and `scannedItems`. - -The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field, status code or contract surface. There is no single type to fold on for a whole-registry sweep, and folding on any one of them would suppress the organization for every type at once; instead `getMetaDiagnostics` reads each swept type through `getMetaItems`, which applies the `allowOrgOverride` read gate to its own request type, so every type is scoped on its own registry flag. A non-overridable type (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into the report. An anonymous or organization-less caller reads exactly what it read before, and the `stats` / `total` / `scannedTypes` arithmetic is unchanged in shape. diff --git a/.changeset/diff-usage-error-to-stderr.md b/.changeset/diff-usage-error-to-stderr.md deleted file mode 100644 index f9345aa844..0000000000 --- a/.changeset/diff-usage-error-to-stderr.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os diff` with no path arguments no longer prints its usage error on stdout — in either face. - -The refusal sat **above** the command's first `if (!flags.json)`, so the face was still undecided when it ran and it fired in **both**. `printError` plus three `console.log` calls — all four writing to stdout — then `process.exit(1)`. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and the streams captured separately, `os diff --json` and bare `os diff` answered byte-identically: exit 1, **141 bytes of prose on stdout, an empty stderr**, and `JSON.parse(stdout)` throwing on the one stream `--json` reserves for the machine. - -The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. The 141 bytes moved intact — stdout 141 → 0, stderr 0 → 141. Nothing else moves: - -- **the exit code is still 1**, so a consumer branching on exit status sees no change at all; -- **the wording is unchanged**, both usage hints included, so a human reading a terminal sees the same four lines; -- **nothing is accepted or rejected differently** — no invocation that worked before fails now. - -⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* on a refusal is an open envelope question, entangled with `os lint --eval --json`'s bare `{ error }` (no `code`, no `httpStatus`), and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. - -This is the sibling of the `resolveConfigPath` repair, and a genuinely different site: that one is reached through `loadConfig()`, this one is `diff.ts`'s own usage error, raised before any config work happens. The existing pin drives `os diff` with two paths precisely so the run gets *past* this check, so it could not see this path. A new pin (`diff-usage-error-stream.e2e.test.ts`) drives the bare form in both faces, and carries a structural tripwire: across 62 command modules, 27 of which offer `--json`, `diff` was the only one with a stdout write above its guard, and the tripwire goes red if another arrives. diff --git a/.changeset/discovery-subscribable-channel-definition.md b/.changeset/discovery-subscribable-channel-definition.md deleted file mode 100644 index 3fed65a35c..0000000000 --- a/.changeset/discovery-subscribable-channel-definition.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor -"@objectstack/runtime": minor ---- - -`/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. - -**A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. - -**The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. - -**Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". - -What to read instead, per case: - -- deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; -- asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. - -Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. - -New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. diff --git a/.changeset/dispatcher-scope-strip-environments-prefix.md b/.changeset/dispatcher-scope-strip-environments-prefix.md deleted file mode 100644 index 3a0451820f..0000000000 --- a/.changeset/dispatcher-scope-strip-environments-prefix.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -An environment-scoped URL now reaches a dispatcher domain instead of answering 404. - -`HttpDispatcher.dispatch()` reads the scoped-URL prefix in three places — the environment-id hint parser, the OAuth-on-MCP gate, and the scope strip that lets `DomainHandlerRegistry` match the remainder. Only the first had been moved to the ADR-0006 `/environments/` spelling; the other two still matched the retired `/projects/` one. The strip therefore never fired on a real scoped URL, and since the registry matches from the head of the path, every environment-scoped request arriving through the `@objectstack/hono` catch-all — the entry cloud hosts mount, and the only one that hands `dispatch()` a still-scoped path — matched no domain at all: - -``` -GET /api/v1/environments//data/task -> 404 ROUTE_NOT_FOUND (now: reaches /data) -GET /api/v1/environments//health -> 404 ROUTE_NOT_FOUND (now: 200) -GET /api/v1/data/task (control) -> reaches /data, unchanged -``` - -The dispatcher-plugin's own scoped mounts were never affected: they pass a pre-stripped subpath (`${prefix}/environments/:environmentId/automation` dispatches the literal `/automation`), which is why the standalone server showed nothing. - -The OAuth 2.1 gate moved with it. An access token is honoured only on the MCP surface, and that test runs against the still-scoped path — so `/api/v1/environments//mcp` would have reached the MCP domain with its token refused had the strip been repaired alone. - -**If you still emit the old spelling**: replace `/api/v1/projects/:projectId/...` with `/api/v1/environments/:environmentId/...`, as `content/docs/api/environment-routing.mdx` has instructed since ADR-0006 D2. That prefix is no longer stripped, and it was never a working alias in the first place: nothing parses `/projects/`, so stripping it discarded the only place the request named an environment and served it from the host default instead. ADR-0006 D2 retired `project` on the API surface with no aliases, so the repair is one spelling in all three readings rather than a two-prefix alternation. diff --git a/.changeset/dist-freshness-declaration-stamp.md b/.changeset/dist-freshness-declaration-stamp.md deleted file mode 100644 index ae718158c2..0000000000 --- a/.changeset/dist-freshness-declaration-stamp.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`check:api-surface` (and every other gate that reads `packages/spec/dist`) no longer refuses a dist that is exactly current because a source file's mtime moved without its bytes changing. - -The freshness rule shared by four gates and the pre-commit hook compares `dist/**/*.d.ts` mtimes against `src/**/*.ts` mtimes. That is the right primitive — it is the artifact those gates consume, and it sees the hand-edited dist and the toolchain change no content digest can — but it cannot tell a real edit from a rewrite that left the bytes alone. A `git merge` re-checks-out an unchanged source file and bumps its mtime; the build that follows correctly does not run, because turbo's cache hashes content, so it is a cache hit that rewrites nothing and leaves every `dist/` mtime where the previous build left it. The gate then refused a correct dist, and prescribed a full rebuild — minutes, under the shared verify lock — of an artifact that needed none. - -The mtime rule keeps its power to convict and gains one way to be answered. `packages/spec`'s build now records a second stamp beside the existing one, `dist/.build-input-hash-dts`, holding the same build-input digest — but written **only** by a build that actually emitted declarations, so `OS_SKIP_DTS=1` leaves it alone. When that digest equals the sources on disk, the declarations demonstrably describe them and the refusal is cleared. The evidence may only ever **acquit**: a missing, unreadable or mismatched stamp leaves the mtime verdict standing, so nothing that passed before can start failing, and the `OS_SKIP_DTS=1`-on-a-built-tree shape that ruled out `dist/.build-input-hash` for this purpose still fails, because that build never refreshes the new file. - -The refusal message was wrong in the same case and is now driven by what was measured: it names a real content change and prints both digests when the stamp disagrees, says plainly that there is nothing to compare against when no stamp exists, and no longer sends every reader after `OS_SKIP_DTS` regardless of cause. It also notes that a repo-wide `pnpm build` may be a cache hit that rewrites nothing, so the remedy names the package build directly. - -The published tarball gains one 65-byte file next to the stamp it already shipped. diff --git a/.changeset/driver-memory-auto-save-interval-ms.md b/.changeset/driver-memory-auto-save-interval-ms.md deleted file mode 100644 index aa551961ff..0000000000 --- a/.changeset/driver-memory-auto-save-interval-ms.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) - - - -**BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and -`FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both -renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of -the authored keys on both persistence arms. - -Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The -option was always milliseconds — it is passed straight to `setInterval` — and the -spec's `min(100)` bound is what made the bare name dangerous rather than untidy: -100 reads as a plausible number of seconds, so an author who guessed the unit -wrong cleared the bound, was refused nowhere, and saved a thousand times more -often than intended. - -Both persistence arms move together: `type: 'auto'` resolves to this same file -adapter and forwards the same field, so this package reads exactly one spelling -rather than two. - -```diff -- new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) -+ new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) -``` diff --git a/.changeset/driver-memory-find-findone-create-honest-types.md b/.changeset/driver-memory-find-findone-create-honest-types.md deleted file mode 100644 index dd41c9d4c0..0000000000 --- a/.changeset/driver-memory-find-findone-create-honest-types.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-memory': minor ---- - -fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the same shape #13878 landed on `update()` / `upsert()` one door over, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). - -`IDataDriver` has always declared `Promise[]>`, `Promise | null>` and `Promise>` on these three doors. The emitted `.d.ts` published `Promise`, `Promise` and `Promise>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record`. They are now declared as the contract declares them. - -What this asks of a consumer holding a concrete `InMemoryDriver`: a caller that reads fields off a `findOne()` result narrows the `null` arm first — the arm the driver has always been able to answer with (`results[0] || null`) and that no caller was ever asked to handle; and a caller that leaned on `any` to read a member off a `find()` row or a `create()` result now types it, since the rows are `Record`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this. - -The parameters are deliberately untouched: `create(data: Record)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed. - - diff --git a/.changeset/driver-memory-notcontains-non-string-value.md b/.changeset/driver-memory-notcontains-non-string-value.md deleted file mode 100644 index 8c67de7e7e..0000000000 --- a/.changeset/driver-memory-notcontains-non-string-value.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/driver-memory": patch ---- - -fix(driver-memory): the reference matcher's `$notContains` arm answers the predicate, not a type test, for a stored non-string value - -`match()` used to answer `{ n: { $notContains: '5' } }` with NO for `{ n: 5 }` — the arm read `typeof value !== 'string' || value.includes(target)`, so a number failed `$contains` (correct) AND its negation (wrong: for the very reason a number cannot contain the substring, it does not contain it). This package's own live mingo path admitted the row, so one filter answered two ways depending on which face was asked; on this face the failure mode was silently dropped rows. - -The arm now answers what `FILTER_TEXT_CASES`' new `score` rows declare on every face (maintainer ruling 2026-09-05 on the contract card): a stored value that is not a string never satisfies a positive text operator and always satisfies `$notContains`. The no-value cells keep their #13166 answer; nothing else in the matcher moved. diff --git a/.changeset/driver-mongodb-test-tsc-program.md b/.changeset/driver-mongodb-test-tsc-program.md deleted file mode 100644 index 79d24ac974..0000000000 --- a/.changeset/driver-mongodb-test-tsc-program.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/driver-mongodb": patch ---- - -fix(driver-mongodb): put the test layer in front of tsc, so the package's own typecheck reports a PASS and not a NUMBER (#14917) - -`packages/drivers/driver-mongodb`'s `tsconfig.json` excluded `**/*.test.ts`, and -its `typecheck` script is `tsc --noEmit` against that very config. Measured at -`6ed4b811af` with the dependency closure built: that program admits **0** of the -package's 30 `src/**/*.test.ts` files while all **10** of its non-test `src/**` -files ARE there, so `pnpm --filter @objectstack/driver-mongodb typecheck` -exiting 0 was a true sentence carrying no information about any test file. - -The filing's headline — that a compile-time `Equals` / `IsAny` pin here is -"checked by nothing" — is **false**, and the correction on the card is right: a -second program does compile these files. `check-type-check-coverage.mjs`'s -`remeasureProject` drops only the test glob and compares the result against its -`TEST_DEBT` ledger. Confirmed here by ablation rather than argued: a -deliberately false `Equals` pin added to `mongodb-driver.test.ts` takes that -program from 10 errors to 11, above the ledger's recorded 10, which reddens it. -The pins were never phantoms. What was true is narrower, and is what this change -closes: the only program reading this layer was a **debt ratchet** — an -instrument that reports a number and fails when the number moves, not a gate -that reports a pass. - -Gives the package the #5286 sibling shape (`packages/rest`, `runtime`, -`objectql`, `core`): a `tsconfig.test.json` with module semantics only — -`esnext` / `bundler` / `lib: ES2022`, matching how vitest actually executes -these files — strictness inherited and untouched, named by the `typecheck` -script via `check:test-typecheck`. - -Measured: **10** errors under the ratchet's shape (matching its recorded number, -and its recorded composition `TS1309 x7, TS2550 x3`, class for class), and **0** -under the split. All 10 were config-tier in full — 7 `TS1309` (`await` at module -scope in a program NodeNext compiles as CJS, because this package has no `"type": -"module"`) and 3 `TS2550` (`Array.prototype.at` against a `lib` older than -es2022). Neither class says anything about a test, and nothing was exposed -behind them: there was no unresolved-import cascade here to collapse, so there -is no `+n` term. `noUnusedLocals` / `noUnusedParameters` are live for this -package (unlike `driver-turso`, which switches both off) and neither fires. - -The `TEST_DEBT` entry (10 errors) is **deleted**, not lowered — the graduation -this ratchet's invariant requires. No `test-typecheck-debt.json` is added: -residue is 0, so none is owed (#5286, maintainer-only to open). That leaves all -30 files unledgered, so any error any one of them gains is red on arrival. - -`check:type-source-resolution` went red from onboarding the new program (the -documented onboarding-limb case, #11490): a registry entry is added rather than -`paths`, with its numbers stated in place — 123 tsc programs / 309 pairs before, -124 / 310 after. The single new pair is `@objectstack/objectql`, a devDependency -that no non-test file in `src/` imports. - -No runtime code changes: not one test file and not one source file is edited, so -no shipped behaviour moves — the suite reports the same 552 passed / 147 skipped -across 30 files as before. The `patch` level reflects the published -`package.json` gaining `typecheck` / `check:test-typecheck` scripts and a `tsx` -devDependency. diff --git a/.changeset/driver-sql-text-operator-non-text-column.md b/.changeset/driver-sql-text-operator-non-text-column.md deleted file mode 100644 index 723cd1d38e..0000000000 --- a/.changeset/driver-sql-text-operator-non-text-column.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -A text operator over a column whose declared type stores no text (`Field.number` and its numeric siblings, `Field.boolean`) now compiles to the contract's declared answer on every dialect, instead of a dialect accident. - -Before: `{ score: { $contains: '5' } }` over a numeric column compiled `col GLOB '*5*'` on SQLite and coerced the REAL in its storage class's spelling (`5` as `'5.0'`, so `$endsWith: '0'` matched every row), `col LIKE $1 ESCAPE $2` on Postgres and was refused at query time with SQLSTATE 42883 (`operator does not exist: real ~~ text` — a 500 for a filter the spec accepts), and `CAST(col AS BINARY) LIKE ?` on MySQL. - -Now (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05): the positive operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) compile to `1 = 0` and `$notContains` to `1 = 1` — the same row set as every JS face, decided from the declared type at compile time because the stored value is not visible until run time. Postgres: a 500 becomes a result. The gate reads the `numericFields` / `booleanFields` registries `initObjects` and `registerExternalObject` already fill; a table this driver was never told about keeps the `LIKE` / `GLOB` it always compiled, every comparand refusal still runs first, and the constants compose with the NULL-safe rules (`$notContains` admits a NULL row already) and the `$not` rewrite. Temporal columns are untouched: their stored value IS text on SQLite, so the contract declares nothing for them. - -`driver-sqlite-wasm` and `driver-turso`'s local transport inherit this compiler. diff --git a/.changeset/driver-sql-update-declared-null.md b/.changeset/driver-sql-update-declared-null.md deleted file mode 100644 index 65b737e404..0000000000 --- a/.changeset/driver-sql-update-declared-null.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): `update()` publishes its honest type — the contract's `Record | null`, not `any` (#14438) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 used for the same door on `@objectstack/driver-memory`). `SqlDriver.update()` was written out with an explicit `Promise` while it has always answered a missing id with `null` (`formatOutput(...) || null` on the un-rotated path, `null` once every rotation shard has been probed). `IDataDriver.update()` declares `Promise | null>`, and an explicit `any` satisfies that structurally — so the emitted `.d.ts` read `Promise` and no caller holding a `SqlDriver`, or a `SqliteWasmDriver` (which inherits the door unchanged), was ever asked to narrow. It is now declared as the contract declares it, and the protected rotation-path producer `rotatedUpdateById()` carries the same type. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members now types them. No runtime behaviour changes. - -`@objectstack/driver-sqlite-wasm` re-declares no `update` member of its own (measured on its emitted `.d.ts`), so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides the door and carries its own entry. - - diff --git a/.changeset/driver-turso-config-timeout-ms.md b/.changeset/driver-turso-config-timeout-ms.md deleted file mode 100644 index d1a80fe5b9..0000000000 --- a/.changeset/driver-turso-config-timeout-ms.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -feat(driver-turso)!: the published connection config names its timeout's unit (#15682, ruling B on #14478) - - - - -**BREAKING** — `TursoConfigSchema`'s `timeout` is renamed to **`timeoutMs`**. The -value is unchanged: the same milliseconds, the same `min(0)` bound, the same -optionality. - -`@objectstack/spec`'s own turso contract renamed the same authored key in -#15680. This package publishes a parallel schema for the same connection config -— the Spec / Studio metadata a host reads to expose Turso configuration UI — so -until now the two declarations of one setting disagreed on its spelling. They -agree again. - -The unit was never in the key name, only in the describe prose, while -`sync.intervalSeconds` — the same shape, three keys above — already spelled its -own. One published config carrying both conventions is what made the bare name -dangerous rather than untidy: an author who has just written -`intervalSeconds: 30` has no reason to read `timeout: 30` as milliseconds, and -nothing in the schema, the type or the parse would have told them otherwise. - -The old spelling is not dropped in silence. `TursoConfigSchema` is a plain -`z.object`, so a bare deletion would have STRIPPED `timeout` and parsed -successfully. The key stays declared as a tombstone instead: `tsc` refuses it on -anything typed `TursoConfig`, and a value that reaches the parse raises a -message naming `timeoutMs` rather than a generic unrecognised-key error. - -```diff -- TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeout: 30000 }) -+ TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeoutMs: 30000 }) -``` - -`TursoDriverConfig` — this package's TypeScript constructor option, a separate -declaration — keeps its `timeout` spelling and is untouched here. diff --git a/.changeset/driver-turso-remote-text-operator-non-text-column.md b/.changeset/driver-turso-remote-text-operator-non-text-column.md deleted file mode 100644 index 34882ade6d..0000000000 --- a/.changeset/driver-turso-remote-text-operator-non-text-column.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -The remote transport compiles a text operator over a declared numeric or boolean column to the contract's declared answer, in step with the local transport. - -`RemoteTransport.buildWhereSQL` compiles filters independently of `SqlDriver` and keeps no schema, so a text operator over a `Field.number` used to compile `"col" GLOB ?` and coerce the REAL in the storage class's spelling (`5` as `'5.0'`). `TursoDriver` now hands the transport its declared-type rule (`setNonTextColumnResolver`, the same shape as the temporal `setFilterColumnSql` rule), answered from the registries `registerRemoteFieldMetadata` already fills at schema sync — so a positive text operator over such a column compiles to `1 = 0` and `$notContains` to `1 = 1` on BOTH transports (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05), instead of a dialect accident. A transport nobody handed the rule to compiles exactly as before, and every comparand refusal still runs ahead of the constant. diff --git a/.changeset/driver-turso-update-declared-null.md b/.changeset/driver-turso-update-declared-null.md deleted file mode 100644 index 28cea18aae..0000000000 --- a/.changeset/driver-turso-update-declared-null.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the `update()` override publishes its honest type — `Record | null`, not `any` (#14438) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` overrides `update()` rather than inheriting it, and the override was written out with its own explicit `Promise` — so this package's emitted `.d.ts` re-declared the door as `any` on its own and would not have picked up the `@objectstack/driver-sql` narrowing. Both of its branches already answered the contract's type: the local branch forwards to `SqlDriver.update()` (narrowed alongside, #14438) and the remote branch passes `RemoteTransport.update()`'s `Record | null` (#14428) through the generic `formatRemoteRow`. The override now declares what it answers. A caller that read fields off the result through the `any` now narrows the `null` arm first. No runtime behaviour changes. - - diff --git a/.changeset/duplicate-record-error-developer-message-wire-spelling.md b/.changeset/duplicate-record-error-developer-message-wire-spelling.md deleted file mode 100644 index 06f899585a..0000000000 --- a/.changeset/duplicate-record-error-developer-message-wire-spelling.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): `DuplicateRecordError.developerMessage` names the wire spelling a client branches on (#14723) - -The envelope's `developerMessage` — the remedy sentence addressed to the -application author — told its reader to "branch on `code === 'DUPLICATE_RECORD'`", -which is the engine's THROWN identity and holds only for an in-process caller of -`engine.insert` / `engine.update`. Every REST route reports the same refusal as -`UNIQUE_VIOLATION`, and since #14723 the per-row reports of the batch and import -surfaces do too, so the sentence was a platform contradicting itself on the one -line an author is most likely to copy. It now says both halves: over the HTTP -API branch on `code === 'UNIQUE_VIOLATION'` on every route, whole-request and -per-row alike; inside the engine the thrown class carries `DUPLICATE_RECORD`. -The class's own docblock says the same. Nothing else about the envelope moves: -`code`, `status`, `cause`, `field`, `object` and the user-facing `message` are -byte-identical, and every pin on the engine's thrown code holds. diff --git a/.changeset/duplicate-source-must-be-a-base.md b/.changeset/duplicate-source-must-be-a-base.md deleted file mode 100644 index 12d539121c..0000000000 --- a/.changeset/duplicate-source-must-be-a-base.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": minor ---- - -`POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. - -Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. - -`copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* - -- **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. -- **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. - -⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. - -Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. diff --git a/.changeset/duration-unit-in-key-name.md b/.changeset/duration-unit-in-key-name.md deleted file mode 100644 index 872f8343d6..0000000000 --- a/.changeset/duration-unit-in-key-name.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.ttl` → `ttlSeconds`, `cache.databaseLoader.ttl` → `ttlMs`, tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) - - - -**BREAKING** rename of seven published authorable keys, shipped as `minor` under -the repo's launch-window convention for breaking changes; every rename is -registered under protocol major 18. Maintainer ruling 2026-09-02 on #14478 -(director decision batch #14, verbatim 「14461 你不处理,其他同意」): **ruled B** — -a spec-source gate for duration-shaped number keys **with no grandfathered -baseline**, plus an ADR-0087 conversion of every offender the ruling named, in -one PR, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」. -⛔ No alias, no transition window: each old spelling is a `retiredKey()` -tombstone whose rejection names the new key. - -## The defect - -`kernel/metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines -apart: `cache.ttl` in **seconds** (default 3600) and `cache.databaseLoader.ttl` -in **milliseconds** (default 60000). Both descriptions named their unit; the -key names did not. An author who copied the outer number into the inner block -got a 3.6-second cache and no error anywhere — the number was valid, the type -was right, the cache was simply cold. `hook.timeout`, `job.timeout` and -`DriverOptions.timeout` had the same shape (milliseconds, said only in prose) -beside siblings that spell theirs (`backoffMs`, `intervalMs`, the body-level -`timeoutMs`). The two tenant keys were worse for the reader who matters most: -`.describe()` is what `content/docs/references/**` publishes and the JSDoc above -a key is not, so `idleTimeout` / `sessionTimeout` said "in seconds" in a source -comment and published a bare `300` / `3600` to the reference page (#14519). - -## FROM → TO - -| schema | before | after | value | -|:--|:--|:--|:--| -| `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) | -| `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) | -| `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) | -| `MetadataManagerConfigSchema` | `cache.ttl` | `cache.ttlSeconds` | unchanged (s, default 3600) | -| `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) | -| `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) | -| `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) | - -```ts -// before -defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 }); -defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeout: 300000 }); -new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } }); - -// after — rename the key; the number is unchanged -defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 }); -defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 }); -new MetadataManager({ cache: { ttlSeconds: 3600, databaseLoader: { ttlMs: 60_000 } } }); -``` - -**Migration.** Rename each key; no value changes. Authoring an old spelling -fails to compile (`tsc`: the input type is `never`) and fails to parse with a -prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a -mechanical D2 conversion (`hook-timeout-to-timeout-ms`, -`job-timeout-to-timeout-ms`, retired from the load path): run -`os migrate meta --from 17` to list the edits for existing sources and apply -them by hand; stored `sys_metadata` rows are rehydrated through the same chain. -The other five keys have no stack seam (runtime config, a per-call options -argument, cloud tenancy config) and carry a semantic entry each. The -`JobScheduleOptions` contract key that carries `job.timeoutMs` to the scheduler -is renamed in lockstep (`timeout` → `timeoutMs`), as is `DatabaseLoaderOptions.cache.ttl` → `ttlMs` in `@objectstack/metadata`. - -## The gate - -`pnpm --filter @objectstack/spec check:duration-unit-keys` -(`packages/spec/scripts/check-duration-unit-keys.ts`, wired into `lint.yml`): -a property whose value is a `z.number()` / `z.int()` / `z.coerce.number()` -chain and whose `.describe()` names a time unit must carry that unit as a token -of its key name (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`, and the -knex-inherited `Millis`), and the token must agree with the prose — `ttlMs` -described "in seconds" is refused too. A `{ value, unit }` pair is recognised -by its sibling `unit` key; duration literals are strings and outside the -population. Calendar positions ("day of the month") and rates ("requests per -second") are skipped. There is no baseline and no `gen:`; a red is a rename -under an ADR-0087 conversion or a describe to fix. diff --git a/.changeset/email-template-shared-read-decoration-strip.md b/.changeset/email-template-shared-read-decoration-strip.md deleted file mode 100644 index b94a340065..0000000000 --- a/.changeset/email-template-shared-read-decoration-strip.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-email": patch ---- - -`plugin-email` strips read decorations with the shared list, not a blanket underscore sweep. - -`readEffectiveTemplate` — the layered read a `DELETE /meta/email_template/:name` runs to restore the packaged baseline an overlay was hiding — removed decorations with a module-local copy of `stripReadDecorations` that dropped **every** key beginning with `_`. The shared list it drifted from, `METADATA_READ_DECORATIONS` in `@objectstack/spec/kernel`, is exactly `['_diagnostics', '_draft']`, and its module header names the ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) as deliberately **not** a member: it is envelope state the write path legitimately carries, and the closed metadata schemas allowlist it so a served document keeps its provenance on re-parse. - -The private copy justified its sweep on the claim that `EmailTemplateDefinitionSchema` "declares no underscore key". That is false — `email-template.zod.ts` spreads `MetadataProtectionFields` into its `strictObject`, so every envelope key is declared and parses clean. The copy was removing keys the schema was deliberately widened to accept, and the list lives in `spec` precisely so a producer and its consumers cannot drift like this. - -The path now calls the shared helper, matching the other read-back-envelope consumers (the dataset query in `rest-server.ts`, the cold-boot flow bind in `service-automation`, `saveMetaItem`'s verbatim persist, and the route-level seed apply). Two behavioural consequences: - -- An underscore key that is neither a decoration nor declared is no longer swallowed before validation. The closed schemas exist to reject exactly that (protocol 17), and the rejection is now reported on the write's own response through the mutation projector, instead of the reset quietly succeeding against a body the schema would have refused. -- The ADR-0010 envelope survives the strip. It still does not reach `sys_email_template`: `upsertDeclaredEmailTemplate` projects the parsed template through `mapTemplateToRow`, a closed column list, and the object declares no underscore column — so no stored row changes shape. There is deliberately no second, envelope-stripping pass beside the shared one; spelling one would re-create the drift this fixes, one layer up. diff --git a/.changeset/engine-refusals-stamp-httpstatus.md b/.changeset/engine-refusals-stamp-httpstatus.md deleted file mode 100644 index bd173d555f..0000000000 --- a/.changeset/engine-refusals-stamp-httpstatus.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -Engine refusals now declare their HTTP status under both spellings: `httpStatus` beside the existing `status`, same number, at every producer in the package. - -`status` is unchanged and stays. It is what every HTTP door in this repo reads — `resolveThrownHttpError` (`@objectstack/types`) resolves `.status` then `.statusCode` and knows no other spelling — so nothing about what the REST or dispatcher doors answer changes. - -What changes is what a consumer holding the **thrown** error can read. ADR-0112 D5 records the destination as "the HTTP status lives on the transport and (optionally) `error.httpStatus`", and `httpStatus` is the key the client SDK already stamps on every wire failure. A consumer that caught an engine refusal locally had no status at all: `os migrate summary-nulls --json --recompute-undefined-on-empty customer.nope` emitted `{ error, code: 'INVALID_FIELD' }` with no status field, while the same refusal arriving over the wire carried `httpStatus: 400`. It now carries `httpStatus: 400` on both paths. - -Additive on thrown errors, so no caller that reads `status` needs to change. The 20 producers: the `INVALID_SORT` / `INVALID_FIELD` / `VALIDATION_ERROR` / `INVALID_METADATA` / `DELETE_RESTRICTED` refusals in `engine.ts`, the `INVALID_FILTER` / `INVALID_FIELD` refusals in `filter-comparand-shape.ts`, `resolveRecomputeScope` in `summary-backfill.ts`, and the eight error classes declaring a `readonly status` (`DuplicateRecordError`, `HookUnscopedDataAccessError`, `MultiUpdateHookKeyDivergenceError`, `EmptyCredentialWriteError`, `SystemWriteOrganizationRequiredError`, `NamespaceConflictError`, `ArtifactObjectNameConflictError`, `ObjectOwnershipConflictError`). diff --git a/.changeset/environment-artifact-checksum-coverage-describe.md b/.changeset/environment-artifact-checksum-coverage-describe.md deleted file mode 100644 index 24aa57019f..0000000000 --- a/.changeset/environment-artifact-checksum-coverage-describe.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The environment artifact's `checksum` now states its own coverage boundary, and `grantedPermissions` states that it sits outside the digest by design. - -Describe text only — no key, value schema or accept-set change on `EnvironmentArtifactSchema`, and the digest itself is computed and verified by the control plane, not here. - -- **`checksum`** carried the shared `Sha256DigestSchema` describe ("SHA-256 digest (64 hex chars)"), which says what the value *is* and nothing about what it *covers*. It now has its own field-level describe: the SHA-256 digest of the canonical JSON serialization of the `metadata` block (stable key ordering), computed by the control plane when assembling the GET response — and coverage stops there, so no other key on the envelope is under the digest. The shared `Sha256DigestSchema` describe is unchanged, so every other digest field still inherits it. -- **`grantedPermissions`** gains one sentence group at the end of its describe: it sits beside `metadata`, outside the digest, and integrity of the granted consent set rests on the carrier — the artifact is environment-local and control-plane served (ADR-0003 / cloud ADR-0007) — an accepted boundary of this envelope rather than an oversight. Its five existing clauses (the manifest-`id` keying, the `sys_package_installation` source, the enforcer consumer, absent ≠ `{}`) are unchanged. diff --git a/.changeset/epoch-instant-and-external-vocabulary-exemptions.md b/.changeset/epoch-instant-and-external-vocabulary-exemptions.md deleted file mode 100644 index 33ae5af20c..0000000000 --- a/.changeset/epoch-instant-and-external-vocabulary-exemptions.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15676, ruling B on #14478) - - - -**BREAKING** — four published epoch-instant keys are renamed and tombstoned. -Shipped as `minor` under the repo's launch-window convention for breaking -changes; the hand-migration prescription is registered under protocol major 18. -Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, because two sibling keys both spelled `ttl` in different units -are indistinguishable at the authoring site. Ruling B exempts two structural -classes from it, and is explicit about the mechanism: both are **declared on the -schema, never in a gate ledger**. This change lands both declarations and -applies them. - -## 1. Epoch instants — the shared `EpochMs` schema - -`EpochMs` (`@objectstack/spec/shared`) is a `z.number().int()` describing -milliseconds since the Unix epoch. A key whose value IS that schema is an -INSTANT, and the gate recognises it structurally — nothing anywhere names the -exempt keys. - -An instant reads to the rule exactly like an offending duration (a bare name -plus a describe that says "milliseconds"), but renaming it the way the rule -prescribes would resolve the wrong confusion. Measured on this package's own -authorable surface: all 51 distinct keys ending in `Ms` are durations -(`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`) and all 51 distinct keys -ending in `At` are instants (`createdAt`, `expiresAt`, `lastUsedAt`). Spelling -an instant `*Ms` would move it INTO the family the rule exists to separate it -from. So the six instants take `EpochMs`, and the four whose name was bare take -the `*At` convention. - -### FROM → TO - -| Schema | Wrote | Write instead | -| :-- | :-- | :-- | -| `api/WebSocketEvent` | `timestamp` | `occurredAt` | -| `api/SimplePresenceState` | `lastSeen` | `lastSeenAt` | -| `kernel/KernelContext` (and `TenantRuntimeContext`) | `startTime` | `startedAt` | -| `kernel/HealthStatus` | `timestamp` | `checkedAt` | - -```ts -// before -const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startTime: Date.now(), features: {} }; -// after — the value is unchanged; only the key name and the declared schema move -const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startedAt: Date.now(), features: {} }; -``` - -Each old key is tombstoned with `retiredKey()`, so it fails `tsc` at the -construction site and fails the parse with the rename prescription rather than -being silently stripped. `kernel/ServiceMetadata.registeredAt` and -`kernel/ScopeInfo.createdAt` were already correctly named and only change -schema — they are not retirements and need no edit. - -⚠️ `api/PresenceState.lastSeen` (`api/realtime-shared.zod.ts`) is a **different** -key holding an ISO-8601 datetime string. It is untouched; do not rename it with -its neighbour. - -**One tightening.** `WebSocketEvent.timestamp` and `SimplePresenceState.lastSeen` -were declared bare `z.number()`, and `EpochMs` is `z.number().int()`, so a -fractional epoch that used to parse at those two sites is now refused. -`Date.now()` has always satisfied it. The other four already declared `.int()`. - -## 2. External-standard mirrors — `.meta({ externalVocabulary })` - -A key whose name is fixed outside this repo carries -`.meta({ externalVocabulary: '' })`. The marker rides -`z.toJSONSchema` verbatim (the channel `xRef` / `xExpression` already use), the -gate honours it, and **the reference page publishes it**: the description cell -now reads `… in seconds (unit per HTTP Cache-Control \`max-age\` (RFC 9111 §5.2.2.1))`. -Publishing it is what makes the exemption honest — the gate exists because a -bare `maxAge` publishes a naked number to a reader who cannot see the source. - -Eleven keys are marked: the three HTTP `Cache-Control` directives, the two CORS -`Access-Control-Max-Age` config keys, the two S3 presigned-URL `expiresIn` keys, -the three better-auth forwarded options, PostgreSQL's `statement_timeout` and -the DNS record `ttl`. No authorable key is renamed or re-typed by this half. - -⛔ Neither exemption is a pass on lying: a marked key still fails -`name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit -other than milliseconds fails the new `instant-unit-contradicts-schema`. diff --git a/.changeset/es-ja-dashboard-gap-source-parity.md b/.changeset/es-ja-dashboard-gap-source-parity.md deleted file mode 100644 index 9ac96e6184..0000000000 --- a/.changeset/es-ja-dashboard-gap-source-parity.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the es-ES and ja-JP `dashboard.gap` help text says what its source now says - -`metadataForms.dashboard.fields.gap.helpText` read `Separación de cuadrícula (unidades -Tailwind)` in es-ES and 「グリッド間隔(Tailwind 単位)」 in ja-JP. Both were faithful -translations of the source they were extracted against, `Grid gap (Tailwind units)` — but -that source has since been rewritten to `Space between widgets, in steps of 0.25rem -(4 = 1rem)`, which deliberately drops the CSS framework unit an app author never chose and -cannot act on, and adds the magnitude the author can size a dashboard with. - -Both leaves kept the retired vocabulary and never gained the magnitude, because bundle -merge fills gaps only: a present-but-stale leaf is not a gap, so no amount of -re-extraction corrects it. They now read `Espacio entre widgets, en incrementos de 0.25rem -(4 = 1rem)` and 「ウィジェット間の間隔、0.25rem 刻み(4 = 1rem)」 — the grid framing is gone -exactly as it is upstream, `widgets` / 「ウィジェット」 is the word each bundle already uses -for dashboard widgets, and the conversion is carried so a Spanish- or Japanese-reading -author can size `gap` without reading the English. - -Two leaves. `columns` is unchanged upstream, so `Columnas de cuadrícula (predeterminado -12)` and 「グリッド列(既定 12)」 stay accurate, and the other 13 source-derived prose leaves -of this subtree (5 section descriptions plus 8 further field help texts) were read against -the current English and are accurate in both locales. diff --git a/.changeset/evaluated-expression-slot-requires-source.md b/.changeset/evaluated-expression-slot-requires-source.md deleted file mode 100644 index bb7db75b05..0000000000 --- a/.changeset/evaluated-expression-slot-requires-source.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention): on the schemas that type an EVALUATED expression -slot — today the `assignment` node's value envelope, -`AssignmentExpressionValueSchema` — an envelope with no `source` the engine can -evaluate is now **refused at authoring**, where it used to parse, register, -pass `objectstack validate`, and then fault at run time. - -Two spellings of one seam, refused by ONE rule with one message at `source` -(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`): - -```yaml -assignments: - digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it - greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form of phase M9.1) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **`ExpressionSchema` is NOT narrowed.** It is the persistence contract — - `source` OR `ast` — and its docblock declares that `ast` becomes required in - build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and - its type `EvaluatedExpression`) is a sibling: the same envelope with `source` - required and non-blank, spelled once and composed by every evaluated slot, so - when AST-only evaluation lands the flip is one edit there rather than a - per-slot unwinding. The rule is worded as "an evaluated slot requires whatever - the engine can actually evaluate"; what that is today is `source`. -- **The notion of blank is the engine's own** — `.trim()`, which - `cel-engine.ts`'s helpers already apply — not a third one beside the shape - rule's `min(1)` and `validateExpression`'s trim. -- **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate` - and the runtime publish gate report a located `error` at the author's own - variable (`config.assignments..source`), and the executor's own shape - pass refuses the same set — all through the spec schema, so none of them - grew a rule of its own. - -**What an author does with a refused envelope.** An assignment value that -carried only `ast` has no evaluable form under M9.1: author its `source`. A -whitespace-only `source` was never an expression: delete the entry, or write -the expression. Every envelope with a non-blank `source` is unchanged, and -nothing is renamed, retired or rewritten — the refusal itself carries the -prescription. - -Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s -silent `false` on an envelope without a `source` — is a behaviour change on a -live path with its own card, and the edge-condition schema that carries that -envelope is narrowed in a follow-up once the in-flight change to -`automation/flow.zod.ts` lands. diff --git a/.changeset/expression-source-non-string-refused.md b/.changeset/expression-source-non-string-refused.md deleted file mode 100644 index 083d5c2b03..0000000000 --- a/.changeset/expression-source-non-string-refused.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/formula": minor ---- - -`validateExpression` now refuses a non-string expression `source` through `errors[]`, instead of throwing a raw `TypeError` that wiped out the caller's located reporting. - -`validateExpression(role, input)` accepts `string | { dialect?, source? }`, and read the envelope's `source` unguarded — `if (!source.trim())`. `ExprInput` declares `source?: string`, but every production call site casts, because the value comes out of **metadata**, where a declaration is a claim about stored data and not a guarantee about it. An envelope whose `source` was present and not a string therefore threw `TypeError: source.trim is not a function` out of a validator whose own docblock promises it never throws. - -**The defect was not "it throws" — it was that it threw the wrong kind and bypassed a whole located-reporting contract.** `AutomationEngine.validateFlowExpressions` collects located findings and throws one assembled error naming the flow, the node, the slot and the source (ADR-0032 §1d); `@objectstack/lint`'s stack walk attributes every finding to the hook, sharing rule, action or field it came from. An exception raised *inside* the shared validator skipped both, so the author was handed an internal message naming none of them. Measured before the fix, on a stack whose `hooks[].condition` was `{ source: { nested: 1 } }`: the whole `objectstack validate` run died on `source.trim is not a function`. After: one located `error` reading ``hook 'gate_hook' (lead) condition``. - -The guard sits at `toSource`, the entry `validateExpression` and `inferExpressionType` share — **once**, not in each caller's own `try`/`catch`, which is the tolerant-consumer shape Prime Directive #12 forbids. `validateExpression` returns `ok: false` with one `ExprValidationError` naming what was found and both authorable forms; `inferExpressionType` answers `'unknown'`, its existing "cannot prove a type". - -**No exported symbol or signature moves** — measured by diffing the built `dist/index.d.ts` before and after: 39 exported declarations on both sides, and `validateExpression`'s declaration byte-identical. What changes is behaviour at a published entry, which is why this is `minor` rather than `patch`: an input that previously produced **no verdict at all** now produces a rejection. - -**What does not change.** Absent, `null`, empty and whitespace-only sources still read as "not authored" (`ok: true`), an `{ ast }` envelope carrying no `source` is still admitted (its admission is `ExpressionSchema`'s rule, not this entry's), and a malformed *string* still gets its own diagnostic — the brace trap, the dialect mismatch, the unknown function — never the shape refusal. No input that previously returned `ok: true` now returns `ok: false`, and none that returned `ok: false` now returns `ok: true`. - -A caller that relied on catching the `TypeError` would need to read `result.ok` instead. None does: all nine production call sites (`@objectstack/lint` ×4, its docs gate ×2, `@objectstack/service-automation` ×3) read `.errors`/`.warnings` directly, and the one call site inside a `try` (`@objectstack/mcp`'s `validate_expression` tool) has a handler-level catch that degrades to an error result and declares its `expression` parameter `z.string()`. diff --git a/.changeset/field-value-domain-write-path.md b/.changeset/field-value-domain-write-path.md deleted file mode 100644 index 8cbcd8dfa1..0000000000 --- a/.changeset/field-value-domain-write-path.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) - -**BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as -`minor` under the repo's launch-window convention for breaking changes. - -The key is **already published, and published unenforced**. The version-packages -cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset -`field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which -declares `Field.valueDomain`, parses it, and refuses it on any type other than -`text` — and never reads it when a record is written. The 17.3.0 liveness ledger -states the gap in its own words: "a non-member WRITTEN to a `text` field -declaring a domain is accepted today". That write is accepted on 17.3.0 and is -refused from this release on. - -**Refused shape**, precisely: a record write that supplies a value for a `text` -field whose definition declares `valueDomain`, where the WRITTEN value is not a -member of the named standard. It fails with the field error code `value_domain`, -carrying `constraint: { valueDomain }` and a message that names the standard in -all four platform locales. Nothing else narrows — a field that declares no -`valueDomain` is untouched, and so is every other field type, because the schema -accepts the key on `text` alone and the validator judges exactly that set. - -**Remedy: write a member of the declared standard.** `iana_time_zone` admits -`UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses -`chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the -`valueDomain` declaration from the field lifts the refusal entirely, for an -author who declared a domain they did not mean. - -**No stored row is touched, and none becomes invalid.** This is the `min` / -`max` / `maxLength` transition-gate class: a value stored before the domain was -declared — or before this release — is never re-read, and it survives an edit of -another field on the same record. An absent or empty value follows the field's -`required` handling, not this check. - - - -- The membership test is the spec's shared `isValueDomainMember` — the same - predicate, over the same closed vocabulary, that a settings specifier's - `valueDomain` uses. A time zone accepted in Settings is the time zone - accepted in a field. -- The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain` - control, shown on exactly the types the schema accepts the key on. The - object-form control's choices are derived from the vocabulary, not re-typed. diff --git a/.changeset/filter-text-non-string-stored-value.md b/.changeset/filter-text-non-string-stored-value.md deleted file mode 100644 index 5632728f89..0000000000 --- a/.changeset/filter-text-non-string-stored-value.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`FILTER_TEXT_CASES` declares what a text operator answers over a stored value that is NOT a string, and the fixture gains its first non-string column. - -Measured before this row existed, one filter over one numeric column answered four ways across the platform: `driver-memory`'s reference matcher said NO to `$contains` and to `$notContains` for the same row; its live mingo path, `formula`, objectql's `having`, `driver-mongodb` and the analytics face type-gated (`$contains` NO, `$notContains` YES); the SQLite family coerced the number to text in its storage class's spelling (REAL renders `5` as `'5.0'`); and live Postgres refused at query time with SQLSTATE 42883 — a 500. - -The maintainer ruled the cell on 2026-09-05 (option A, type-gate): a stored value that is not a string never satisfies a positive text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` — complementarity holds, on every face. Coercion was refused on the measurement; a declared-type door that refuses the filter before any backend runs is deferred to its own decision card, not rejected. - -- `FilterTextRow` is now `{ id, name, score }` — `score` is a NUMBER on every row (a `0` among them), chosen so a coercing backend answers a visibly non-empty set and a truthiness guard drops a row. -- Five new evaluated rows over `score`: the four positive operators the table can carry answer `[]`, `$notContains` answers all nine. (`$like` / `$ilike` follow the same rule and are pinned on the faces that answer them — the table is a driver's enrolment and `driver-mongodb` refuses those two.) -- `NON_TEXT_STORED_VALUE_TYPES` (`field-value.zod.ts`) — the numeric and boolean value classes, i.e. the declared field types whose stored value is never text — is the list the SQL faces classify a column by at compile time, since they cannot read the value. Temporal types are deliberately absent: their stored form is a dialect question (ADR-0053) the row does not decide. - -Every suite that materialises the fixture adds the column (SQL `initObjects` DDL included). diff --git a/.changeset/filter-text-operator-declared-type-door.md b/.changeset/filter-text-operator-declared-type-door.md deleted file mode 100644 index 5a20137cef..0000000000 --- a/.changeset/filter-text-operator-declared-type-door.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661) - - - -**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny. - -The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares. - -New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes. - -The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set. - -What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant. diff --git a/.changeset/find-afterfind-array-guard.md b/.changeset/find-afterfind-array-guard.md deleted file mode 100644 index b9b8ad18da..0000000000 --- a/.changeset/find-afterfind-array-guard.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": minor ---- - -`ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. - -`find()` is declared `Promise`, but on the hook path it returned `hookContext.result` with nothing re-checking the value after the `afterFind` dispatch. A handler assigning `ctx.result = { records: [ … ] }` therefore made a read declared to resolve to an array resolve to an envelope instead — silently, with no throw, no diagnostic and no log, while roughly 140 call sites read the answer as an array on the strength of the declaration. - -The engine now refuses that, immediately after the `afterFind` dispatch and ahead of the two consumers that already assume the array (secret-field masking and the `__search` companion strip). The refusal is a named error, `FindHookResultNotArrayError`, carrying the registered ADR-0112 code `FIND_HOOK_RESULT_NOT_ARRAY` and HTTP `500`; its message names the hook event and the object, and `developerMessage` carries the remedy. - -**Shaping stays legal, and nothing about it changes.** A handler may still mutate rows in place, delete keys, filter rows out, or assign a *different array* built from them — `Array.isArray` is the whole predicate, deliberately, so that `ctx.result = ctx.result.map(…)` keeps working. Only the container is protected. - -What to do if this refusal fires: - -- to answer no rows, assign `[]`; -- to refuse the read, `throw` from the handler — the supported way for any hook guard to say no; -- to hand a caller a different structure, build it in the caller, not in the hook. - -`@objectstack/spec` widens by one member: `FIND_HOOK_RESULT_NOT_ARRAY` joins `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the generated `ErrorCode` union — and therefore `ApiErrorSchema.code` — accepts it. Additive: no existing code is removed or renamed. - -Scope: this closes the one `return hookContext.result` site in the engine with a concrete declared shape to violate. `findOne`, `update` and `delete` declare `Promise` and carry no enforceable declaration; that is a separate question about those declarations and is deliberately not answered here. diff --git a/.changeset/flow-action-record-load-signal.md b/.changeset/flow-action-record-load-signal.md deleted file mode 100644 index 3fad885942..0000000000 --- a/.changeset/flow-action-record-load-signal.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168) - -The previous release declared `AutomationContext.recordLoadDenied?: true` and -said so plainly: **declared, not yet populated on the flow face.** The -script/body face of both action doors emitted the signal, but -`dispatchFlowAction` handed `automation.execute` a context without it, so a -`runAs: 'system'` flow that guarded on the documented key was inert — never -`true`, never wrong, and indistinguishable from a flow whose caller could read -the row. - -**This release populates it, on both doors in one stroke** — REST -`POST /api/v1/actions/...` and the MCP `run_action` bridge: - -```js -// a runAs:'system' flow, guarding before it acts on the subject row -if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ } -``` - -- **The exact producer shape, unchanged.** The one shared producer - (`loadActionSubjectRecord` → `actionRecordLoadSignal`) already returns - `{ recordLoadDenied?: true }`, and the flow door now spreads it as a - **sibling of `record`** — never a key on the record, and **absent**, never - `false`, when nothing was refused. So a flow reads it exactly as a handler - does, `recordLoadDenied === true`. -- **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the - load OUTCOME (`subject`) instead of a bare `record`, and derives both the - record and the signal from it. A caller can no longer forward the row while - dropping the verdict that says the caller could not read it — the omission is - a compile error rather than a guard silently inert one door over, which is - the defect the handler-face signal was filed for. -- **Purely additive.** Nothing is refused that was not refused before, no - existing key changes value, and the `recordId` stamp is deliberately kept: - `record.id` still arrives exactly as it did, which is why the flag — and not - `record.id` — is the authorization predicate. Whether the automation engine - *acts* on the key (a flow-level refusal, a step condition) is a separate - decision and is deliberately not part of this change. -- **`@objectstack/spec` (docs only).** The contract's "not yet populated on the - flow face" sentence is retired; no type changes. diff --git a/.changeset/flow-edge-id-uniqueness.md b/.changeset/flow-edge-id-uniqueness.md deleted file mode 100644 index a69e7ad3c7..0000000000 --- a/.changeset/flow-edge-id-uniqueness.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964) - - - -**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]` -carries two edges with the same `id` is now **refused at parse time** — by -`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a -flow through the schema (`objectstack validate`, the runtime publish gate, a -stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under -the repo's launch-window convention for breaking changes. Maintainer ruling -2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option -A — an `error`, not a `warning`; no opt-out, no transition window. - -Every reader of an edge id assumes the ids in a flow are unique — a designer, -a BPMN export, a flow diff, any traversal that dedupes by id — and nothing -enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped -through two releases of green CI in a downstream app and was inert only -because the engine keys out-edges by `source`, never by `id`: the collision is -invisible until something keys on ids, and then silently wrong rather than -loudly broken. The id space is hand-authored, so the next author picking a -"free" id from the sequence had no way to know it was taken. - -**What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine` -on the flow's `edges[]`. Each later occurrence of an already-declared id raises -one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming -both positions, so the formatted error points at the edge to renumber: - -```text -✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: … -``` - -**What does NOT change:** `edges[].id` keeps its name, type and describe; the -node vocabulary, the edge `type` enum and every other refusal are untouched; -a flow with unique edge ids (or no edges) parses exactly as before. Node ids -are not covered by this change. - -The shape that is refused, and what the author does about it — a two-edge -excerpt, the later edge renumbered: - -```ts -// before — parsed on green, both edges keyed 'e20' -edges: [ - { id: 'e20', source: 'qualify', target: 'convert' }, - { id: 'e20', source: 'convert', target: 'end' }, -] - -// after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one: -edges: [ - { id: 'e20', source: 'qualify', target: 'convert' }, - { id: 'e21', source: 'convert', target: 'end' }, -] -``` - -**Remedy.** Renumber the later edge to an id no other edge in that flow -carries; nothing else in the flow needs to move. The census over this -repository found no flow to migrate, so this is a release note, not a -migration: no shipped example, fixture or seed in `packages/**` or -`examples/**` declares a duplicate edge id, and the pinned objectui tree -carries none in its authored flows. The one known downstream instance was -renumbered before this change (hotcrm PR #1571). diff --git a/.changeset/flow-end-node-refused-outcome.md b/.changeset/flow-end-node-refused-outcome.md deleted file mode 100644 index 38741c5de7..0000000000 --- a/.changeset/flow-end-node-refused-outcome.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`. - -Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type. - -The contract, declared here first (the engine and runner halves follow in their own packages): - -- **`end` node config** — `EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was. -- **Run row** — `ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run. -- **Result / wire** — `AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast. - -Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before. diff --git a/.changeset/flow-node-id-uniqueness.md b/.changeset/flow-node-id-uniqueness.md deleted file mode 100644 index 16d7a003db..0000000000 --- a/.changeset/flow-node-id-uniqueness.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowSchema` refuses a flow whose top-level `nodes[]` declares the same id twice (#15713) - - - -**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose top-level -`nodes[]` carries two nodes with the same `id` is now **refused at parse time** -— by `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that -validates a flow through the schema (`objectstack validate`, the runtime -publish gate, a stack's `flows[]`) — where it used to parse on green. Shipped -as `minor` under the repo's launch-window convention for breaking changes. The -exact parallel of #14964 (edge ids, maintainer ruling 2026-09-05, option A — -an `error`, not a `warning`; no opt-out, no transition window), applied to the -other hand-authored id space in the same schema. - -Every edge's `source` / `target` names a node by id, and the engine's traversal -picks out-edges by `source` — with two nodes sharing an id, every edge from -that id is ambiguous and whichever node wins is decided by array order, -silently. A designer, a BPMN export and a flow diff key on node ids the same -way they key on edge ids. Only region bodies (`loop` / `try_catch` / `parallel` -sub-graphs) were checked, by `analyzeRegion` at `registerFlow()`; the flow's -own top-level `nodes[]` parsed with the collision intact — measured on -`origin/main` `1f2a02ba` with two lit controls on the same schema instance (a -node missing its `label` → refused at `nodes.1.label`; an unknown key on a node -→ `unrecognized_keys`). - -**What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing -`superRefine` on `FlowSchema` gains a pass over the top-level `nodes[]`, the -same shape as the `edges[]` pass. Each later occurrence of an already-declared -id raises one `custom` issue, anchored at `nodes[N].id` of the *later* node and -naming both positions, so the formatted error points at the node to rename: - -```text -✗ nodes.2.id: Duplicate node id `n` — `nodes[2]` reuses the id already declared by `nodes[1]`; every node id in a flow must be unique. Rename one of them: … -``` - -**What does NOT change:** `nodes[].id` keeps its name, type and describe; the -open node-type vocabulary (ADR-0018), the region rules (`analyzeRegion`) and -every other refusal are untouched; a flow with unique top-level node ids -parses exactly as before. The rule judges the flow's **own top-level** -`nodes[]` only — a region body's nodes remain `analyzeRegion`'s to judge, and -whether a region node may reuse a top-level node id (one id space or two) is a -separate decision (#16134) this change neither takes nor pre-empts. - -The shape that is refused, and what the author does about it — a four-node -excerpt, the later node renamed and its edge re-pointed: - -```ts -// before — parsed on green, two nodes keyed 'n' -nodes: [ - { id: 'start', type: 'start', label: 'Start' }, - { id: 'n', type: 'assignment', label: 'Assign A' }, - { id: 'n', type: 'assignment', label: 'Assign B' }, - { id: 'end', type: 'end', label: 'End' }, -] - -// after — refused at parse (nodes.2.id: Duplicate node id `n` …); rename the later one -// and point the edges that meant it at the new id: -nodes: [ - { id: 'start', type: 'start', label: 'Start' }, - { id: 'n', type: 'assignment', label: 'Assign A' }, - { id: 'n2', type: 'assignment', label: 'Assign B' }, - { id: 'end', type: 'end', label: 'End' }, -] -``` - -**Remedy.** Rename the later node to an id no other top-level node in that -flow carries, then re-point at the new id the edges whose `source` / `target` -meant that node; nothing else in the flow needs to move. The census over this -repository found no flow to migrate, so this is a release note, not a -migration: no shipped example, fixture or seed in `packages/**` or -`examples/**` declares a duplicate top-level node id. diff --git a/.changeset/flow-record-decoupled-from-batch-payload.md b/.changeset/flow-record-decoupled-from-batch-payload.md deleted file mode 100644 index c22cd1cfe5..0000000000 --- a/.changeset/flow-record-decoupled-from-batch-payload.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/trigger-record-change": patch ---- - -fix(trigger-record-change)!: the record handed to a record-change flow no longer aliases the write's payload (#14744) - - - -**BREAKING** for a flow whose `script` node mutates a NESTED value of the -triggering record IN PLACE: that mutation no longer affects the write the flow -was triggered by. Shipped as `patch` — this change moves no public surface (no -exported symbol, no accepted key or value), and under the maintainer's -2026-09-04 rule (decision batch #35, on #15294) a `fix(` that changes no public -surface stays `patch`, with breaking-ness carried by this banner and the -ADR-0087 disposition rather than by the level. Maintainer ruling 2026-09-04 on -#14744 (decision batch #38, verbatim 「同意」), adopting option A. - -**Why.** `buildContext` builds the flow's `record` as a shallow overlay of the -pre-image, the mutation payload and the after-row. The top-level object was -new, so a flow ASSIGNING a top-level key reached nothing — but every nested -value in it was the engine's own object, shared by reference. One of those is -`ctx.input.data`, and on a `multi: true` update ADR-0058 Addendum II D3 hands -every per-row context that same payload object, which is the SET clause of the -single `updateMany`. A registered function doing `record.tags.push(...)` -therefore wrote the SET clause without assigning any key: every dispatch's -contribution landed on EVERY matched row, including values derived from another -row's pre-image, and #14099's key-set refusal could not see it because no key -was assigned. Measured end to end on the memory driver and on -`@objectstack/driver-sql` (#15356). - -**What changes.** Both flow-facing roots — `record` (and the `params` alias of -it) and `previous` — are decoupled from the engine's state before the flow -runs. Arrays, plain objects, `Date`, `RegExp`, `Map` and `Set` are copied; -primitives, functions and other class instances are shared, which is the -documented and pinned boundary. A flow still mutates its roots freely and still -observes its own writes for the rest of the run; those writes simply reach -nothing outside it. `previous` is decoupled in the same stroke because it is the -engine's single pre-image object and the same hook context reaches every other -flow bound to the same write. - -**What does NOT change.** The engine's write shape. ADR-0058 Addendum II D3 -stands untouched: one payload still serves N rows and every per-row context is -still handed that one object. #14099's key-set refusal is untouched and is not -widened — a hook that assigns the same key with per-row values still passes it, -and divergent key sets are still refused whole. Flow metadata with no registered -function reached nothing before this change and reaches nothing after it: -assignment nodes write the run's variable map, and `update_record` issues its own -by-id write. Lookup expansion (`config.expand`) still grafts onto the record the -flow holds. - -**Consumer note.** A flow that relied on an in-place nested mutation to persist -— which on a by-id write did persist, and on a `multi: true` write corrupted -every other matched row — writes the record with the `update_record` node -instead. That node is the supported per-row write and is unaffected by this -change. diff --git a/.changeset/gantt-tree-config-close-passthrough.md b/.changeset/gantt-tree-config-close-passthrough.md deleted file mode 100644 index c1305ed428..0000000000 --- a/.changeset/gantt-tree-config-close-passthrough.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `GanttConfigSchema` / `TreeConfigSchema` refuse undeclared keys — both `.passthrough()` windows are closed and the ten gantt members plugin-gantt read through the window are declared (#15469) - - - -**BREAKING** accept-set narrowing on two published authorable config blocks — -`ListView.gantt` (`GanttConfigSchema`) and `ListView.tree` (`TreeConfigSchema`) -in `@objectstack/spec/ui`, reached through every view door (`defineView`, -`objects[].listViews`, the `view` metadata type): an UNDECLARED key inside -either block is now **refused** at parse with the `strictObject` named error -(`unrecognized_keys`; surface named, key echoed, closest declared key -suggested), where it used to pass through silently. Shipped as `minor` under -the repo's launch-window convention for breaking changes. Maintainer ruling -2026-09-05 on #15469 (director decision batch #41 item 2, verbatim 「同意」): -option A for both sites. - -Both blocks were `strictObject(…).passthrough()` — the campaign's own helper -applied and immediately undone, so `colourField` on a gantt block parsed green -and rendered an uncoloured bar while the same typo on a calendar or timeline -block got a named refusal. One `strictObject` applied and then undone is two -contracts on one surface (Prime Directive #12); the renderer-ahead window it -kept open is shut, and a renderer knob is declared in the spec before it is -read. - -**Newly declared on `GanttConfigSchema`** — all optional, types measured from -objectui's `GanttConfigExtensionFields` (`@object-ui/types/zod`) at pin -`a472b07`, each with a describe saying what plugin-gantt does with it: - -- `borderColorField: string` — field carrying a per-task alert stroke color -- `lockField: string` — field marking a row view-only (truthy = locked) -- `objectField: string` — field carrying the row's own object API name (mixed-object trees) -- `summaryExtent: 'children' | 'self'` — how a summary bar's span is computed -- `defaultCollapsedDepth: integer ≥ 0` — auto-collapse nodes at or below this depth -- `dependencyTypes: boolean` — whether the store persists dependency link types -- `timeZone: string` — IANA business time zone the calendar renders in -- `exportFileName: string` — base name for exported PNG / PDF files -- `interactions: { move?, resize?, progress?, link? : boolean }` — per-interaction switches (closed sub-object) -- `timeSegments: { dayStart?: string, bands: [{ key?, label, start, end, color? }], showMidnight?: boolean }` — shift segmentation for the day-mode timeline (closed sub-objects) - -**`TreeConfigSchema` declares nothing new.** plugin-tree's `getTreeConfig` -(objectui `a472b07`) reads exactly the four keys already declared — -`parentField`, `labelField`, `fields`, `defaultExpandedDepth` — from the `tree` -block, so the close refuses only what no renderer ever read. - -**Who is affected (measured, objectstack `f7db8f4fd`):** zero gantt or tree -blocks under `examples/**`, `content/docs/**`, `skills/**` or any package -fixture author one of the ten keys or any undeclared key; objectui's own gantt -fixtures author the ten and keep parsing because the keys are now declared. A -block carrying a key outside the declared set — a misspelling such as -`colourField`, or a renderer knob authored ahead of its declaration — is refused -on upgrade with the key named; fix the spelling, or declare the knob in the spec -first. diff --git a/.changeset/generated-migration-audit-stamp-timestamptz.md b/.changeset/generated-migration-audit-stamp-timestamptz.md deleted file mode 100644 index 8cb6de3857..0000000000 --- a/.changeset/generated-migration-audit-stamp-timestamptz.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration --format sql` gives every timestamp column the time zone the platform actually stores. - -The SQL format spelled its two audit-stamp columns — and every declared `datetime` field — as bare `TIMESTAMP`. In PostgreSQL that is `timestamp WITHOUT time zone`, while both of the other producers of the same columns yield `timestamptz`. This implements **[ADR-0053](../docs/adr/0053-date-and-datetime-semantics.md) D-B4** (accepted), which governs both sites in one sentence: `Field.datetime` maps to `DATETIME(3)` on MySQL while "Postgres deliberately keeps `timestamptz`", and "the builtin `created_at`/`updated_at` take the same type — the registry declares them `Field.datetime`". So the declared-field row and the audit-stamp rows are one decision rather than two judgement calls, and `driver-sql`'s `createAuditTimestampColumn`, its `createColumn` `datetime` arm and this CLI's TypeScript migration format were already implementing it — the SQL format was the one producer that was not. - -Driven, not compiled: all three producers were run against a live PostgreSQL 16.13 and their columns read back out of `information_schema.columns`. Only the SQL format came back zone-naive, and the consequence is a data defect rather than a cosmetic type difference. A zone-naive column stores the wall clock of whatever session wrote the row and keeps nothing to recover the offset from, and `DEFAULT now()` is folded into that session's `TimeZone` on the way in. Two defaulted rows inserted **six milliseconds apart**, one under `TimeZone='UTC'` and one under `Asia/Tokyo`, were recorded **nine hours apart** in the generated table and 3 ms apart in the driver's own: - -``` -sqlgen (timestamp) a_utc 2026-09-05 22:31:28.309421 -sqlgen (timestamp) b_tokyo 2026-09-06 07:31:28.315458 <- +9h, same instant -tsgen (timestamptz) a_utc 2026-09-05 22:31:28.31332+00 -tsgen (timestamptz) b_tokyo 2026-09-05 22:31:28.316401+00 -``` - -The whole temporal class was enumerated in that same run and `datetime` is its only divergent member: `date` is `DATE` and `time` is `TIME` on all three producers, so neither moves. - -Two things this deliberately does not change. The audit columns' **nullability** stays as it is: the driver leaves both nullable and both generators say `NOT NULL`, nothing fails either way, and the driver's own audit DDL is dialect-branched in a way a Postgres-flavoured generated migration does not reproduce — so which side moves is a ruling, recorded in `generate-builtin-id-column.pin.test.ts` and still open. The `DEFAULT now()` spelling stays too: it is the same instant as the driver's `CURRENT_TIMESTAMP` (both are `transaction_timestamp()`) and only reads differently in the catalog. - -Scope for an existing project: already-generated migration files are checked-in artifacts and are not rewritten, and no deployed column is altered — a table created from an older generated migration keeps `timestamp without time zone` until its owner migrates it. What changes is what the next generated migration says. diff --git a/.changeset/generated-migration-id-column-shape.md b/.changeset/generated-migration-id-column-shape.md deleted file mode 100644 index 2d557f03d6..0000000000 --- a/.changeset/generated-migration-id-column-shape.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration` gives a table's own `id` column the shape the platform actually creates. - -Both migration generators hardcoded the primary key as a UUID — `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()` in the SQL format, `table.uuid('id').primary().defaultTo(db.fn.uuid())` in the TypeScript one (the default format). The platform's SQL driver emits `table.string('id').primary()`, which is knex's `varchar(255)`. A platform id is a string, not a uuid, so on Postgres the generated table refused the platform's very first insert with `22P02 invalid input syntax for type uuid`. - -The quieter half is the `DEFAULT`, and it is why this was worth correcting rather than working around. The driver emits no database-side default at all — its insert path always supplies the id itself — so `gen_random_uuid()` never fired for a platform write, only for an out-of-band one, handing that row a 36-character uuid this platform's id generator would never mint. One table would then hold two incompatible id shapes, with nothing said. - -Both generators now emit the driver's own answer: `"id" VARCHAR(255) PRIMARY KEY` and `table.string('id').primary()`. The correction also closes a contradiction inside the generator file, whose prose already stated that a reference column takes the width of the target's `id` column *because* the driver emits `table.string('id').primary()` — a few hundred lines above the two lines that emitted `uuid`. - -`generate-builtin-id-column.pin.test.ts` reads the width from the driver's own `DEFAULT_STRING_VARCHAR_CHARS` rather than transcribing `255`, so the generators cannot drift away from the driver again without a named failure. diff --git a/.changeset/great-clouds-repair.md b/.changeset/great-clouds-repair.md deleted file mode 100644 index 082e4063dd..0000000000 --- a/.changeset/great-clouds-repair.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -`GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null` - -The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor. diff --git a/.changeset/history-cleanup-failing-run-is-not-silent.md b/.changeset/history-cleanup-failing-run-is-not-silent.md deleted file mode 100644 index 15d38ecd95..0000000000 --- a/.changeset/history-cleanup-failing-run-is-not-silent.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/metadata': patch ---- - -fix(metadata): a `HistoryCleanupManager` run that loses deletes now says so, at both of `start()`'s triggers - -A failing history cleanup was completely silent. Three things composed: every inner `catch` on the delete path is a bare `catch {`, so the error object is discarded; the only `console.error` in `runCleanup()` sits in its OUTER catch, which those inner catches prevent execution from reaching; and `start()` invoked the run as `void this.runCleanup()`, throwing away the `{ deleted, errors }` the run returns — at BOTH call sites, the immediate run and every interval tick. A driver whose deletes failed on every scheduled run therefore produced zero output and no reachable error count, while the history table grew past its retention policy with nothing to find. - -The repair reads the envelope instead of replacing it. `runCleanup()`'s contract, its inner catches and its counting are unchanged: reporting a failure to the CALLER is the third answer AGENTS.md → "Degradation log levels" allows a durability seam, and that same section names a log per failed write as the mirror-image failure. What was missing was a reader — `start()` is where the chain ends, since it returns `void` and an interval tick has no caller at all. Both call sites now go through one shared pass that reads the returned counts and, when a run lost deletes, prints one `error` line naming the consequence (rows past the retention policy are still in the table, nothing retries them, and the system keeps reporting healthy) and where to look. A run that loses nothing stays quiet, and a direct caller of `runCleanup()` sees exactly the same `{ deleted, errors }` as before. diff --git a/.changeset/history-cleanup-utc-retention-cutoff.md b/.changeset/history-cleanup-utc-retention-cutoff.md deleted file mode 100644 index d44ffc6be2..0000000000 --- a/.changeset/history-cleanup-utc-retention-cutoff.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`HistoryCleanupManager` computes its retention cutoff on one calendar, not two. - -Both call sites — the age-based delete in `runCleanup()` and the preview count in `getCleanupStats()` — built the cutoff with `cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays)` and then rendered it with `toISOString()`. `setDate`/`getDate` read and write the **local** calendar; `toISOString()` renders **UTC**. They now use `setUTCDate`/`getUTCDate`, so the arithmetic and the rendering agree. - -`setDate` preserves wall-clock time, so shifting the local calendar back `n` days moves the *instant* by exactly `n × 24h` only while every local day in the window is 24 hours long. When the window straddles a DST transition it is 23 hours (spring-forward) or 25 (fall-back), and the cutoff instant that goes into the `recorded_at: { $lt: … }` **delete** filter is off by the size of that transition — one hour in most zones, thirty minutes on Lord Howe Island. History rows within that slip of the retention boundary were deleted early, or retained too long. - -The exposure is not limited to the two transition days: the window only has to *straddle* a transition, so it grows with `maxAgeDays`. Measured over a 12-zone × 366-day × 48-half-hour sweep of 2026, in `America/New_York` the old spelling produced a wrong cutoff for 0.6% of instants at `maxAgeDays: 1`, 16.4% at 30, 49.7% at 90 and 69.4% at 180. In zones that do not observe DST (`UTC`, `Asia/Shanghai`, `Asia/Kolkata`, `Australia/Perth`) the rate is 0.0% at every `maxAgeDays` — which is why no test had ever gone red on this. - -This does **not** make retention timezone-aware, and does not change what `maxAgeDays` means. The cutoff was already intended to be `now − maxAgeDays × 24h`; it is now that in every zone rather than only in zones without DST. Nothing else in either filter moved: the `organization_id` scoping, the ADR-0009 `executionPinned` exclusion and the `maxVersions` path are untouched. diff --git a/.changeset/hono-auth-mount-owned-404-not-yielded.md b/.changeset/hono-auth-mount-owned-404-not-yielded.md deleted file mode 100644 index cfafa0f524..0000000000 --- a/.changeset/hono-auth-mount-owned-404-not-yielded.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/hono': patch ---- - -The Hono adapter's `/auth/*` mount yields only a 404 that disclaims ownership - -`createHonoApp`'s `${prefix}/auth/*` mount forwards every request under it to the -kernel's `auth` service and, since #4117, hands the request on to the rest of the -chain when that service answers 404 — which is what keeps `/auth/me/permissions` -and `/auth/me/localization` reachable through the gated `dispatch()`. The yield -had only the status to go on, so it could not tell "I do not serve this path" -from "I serve it and the answer is 404". - -Measured on a real boot through this adapter (a real kernel with `AuthPlugin`, -`prefix: '/api/v1'`), `GET /api/v1/auth/delete-user/callback?token=…&callbackURL=…` -answered `404 {"message":"Not found","code":"NOT_FOUND"}` from better-auth and -`200 {}` on the wire. `plugin-auth`'s route ledger carries that route under its -`disabled` disposition precisely because it is published and answers 404, so the -ledger's recorded answer was true of the auth service and false on this adapter's -wire. Nothing had to be composed in for that: the `${prefix}/*` dispatcher -catch-all this same function registers is terminal and answers `200 {}` for paths -under `/auth/`. - -The mount now asks the auth service whether its own router serves the path, via -an optional `ownsRoute(request)` — the seam `AuthManager` grew in the plugin-side -fix for the same defect — and yields only when it does not. Every answer that is -not a literal `true` (no such method, a throw, anything else) means yield, so a -service predating the method behaves exactly as before and a failure to decide -can never cost the ordering-independent surface. - -⛔ The mount is unchanged and still claims `${prefix}/auth/*`; 401/403 were never -yielded and still are not. What narrowed is only which 404 may be handed on. diff --git a/.changeset/hono-me-localization-user-locale.md b/.changeset/hono-me-localization-user-locale.md deleted file mode 100644 index 21d883db39..0000000000 --- a/.changeset/hono-me-localization-user-locale.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/plugin-hono-server": minor ---- - -feat(hono-server): `GET /auth/me/localization` → `locale` is now the signed-in user's language — `sys_user.locale` when set, then the request's `Accept-Language`, then the deployment default (#14788) - -Maintainer ruling 2026-09-03 (option D on #14788): this endpoint is the ONE -read face for "what language is this user", now that `sys_user.locale` is a -user-stated preference (#13881 / #14787) and the never-produced -`SessionUser.language` is retired from the session contract -(`@objectstack/spec`, same release). - -What changed, for an authenticated caller: - -- `locale` resolves **the user's own `sys_user.locale`** first — read under a - system context by the caller's own id and accepted only when it passes the - column's OWN `locale_bcp47_shape` rule as the registry declares it (the - endpoint evaluates that rule; it carries no second locale parser). A - malformed, blank or unverifiable value falls through, it is never served. -- then **the request's `Accept-Language`** preference (`preferredLocaleFromHeader`, - the same parse REST and the runtime dispatcher feed `execCtx.locale` from); -- then **the deployment default** (`resolveLocalizationContext` — the - `localization.locale` settings cascade, floor `en-US`). - -Before, the resolver behind this endpoint assembled no localization at all, so -`locale` was `null` for every authenticated caller; it is now always a string -for an authenticated caller. The response shape is unchanged -(`{ authenticated, currency, locale, timezone }`), `currency` / `timezone` -are untouched, and the unauthenticated answer (`{ authenticated: false }`) is -unchanged. `resolveSignedInUserLocale` is exported for hosts that compose the -current-user endpoints directly. diff --git a/.changeset/host-importer-aliased-dual-publish-entry.md b/.changeset/host-importer-aliased-dual-publish-entry.md deleted file mode 100644 index 69d7c1c852..0000000000 --- a/.changeset/host-importer-aliased-dual-publish-entry.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/types": patch ---- - -`createHostImporter` now loads the `import` build of an ALIASED dual-published package, instead of silently keeping its `require` build. - -An alias declaration — `{"dependencies": {"foo": "npm:bar@1"}}` — installs a package whose manifest is named `bar` under the key `foo`. On the path where CommonJS resolution SUCCEEDS, the importer re-decides only the CONDITION (it asks the package which entry an `import()` gets, so the caller's ESM chain and this load share one instance). That re-decision recognised the package root by walking up from the resolved entry until it found a manifest named after the DECLARATION KEY — `foo` — while an aliased install's manifest is named `bar`. The walk therefore never matched, the re-decision produced nothing, and the load fell back to whatever the CommonJS resolver had answered: the `require` condition. - -For an aliased dual publish that left the process holding two live copies of one package — the CommonJS build behind the host importer, the `import` build in the caller's own chain — which is exactly the split the condition re-decision exists to remove: a plugin registry, a singleton kernel, a module-level cache, one copy each. - -The expectation now comes from the host's own declaration (`npm:name@range`, aliased `workspace:name@range`), the same reading the ESM-only fallback finder has used since it learned about aliases. Nothing about the check's strictness moves: an alias naming one package still does not license a directory holding another, and a non-aliased declaration is still verified against its key. Declarations that name a LOCATION rather than a package (`link:`, `file:`) carry no name to expect, so they keep today's behaviour unchanged. - -Measured population for the behaviour change: zero aliased declarations exist across this workspace's 875 dependency declarations, and 867 of 867 installed declarations already match their key — no ordinary, non-aliased install reaches this path. diff --git a/.changeset/i18n-coverage-inline-locale-map-authored.md b/.changeset/i18n-coverage-inline-locale-map-authored.md deleted file mode 100644 index 802808905b..0000000000 --- a/.changeset/i18n-coverage-inline-locale-map-authored.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint` / `os i18n check` stop reporting a written inline locale map as an untranslated string. - -`I18nLabelSchema` authorizes two forms of a display label: a plain string, whose translations live in a bundle, and an **inline locale map** — `{ en: 'Members', 'zh-CN': '成员' }` — written out at the authoring site and picked at render time. Rulings on both forms make the map the one localisation route for props that have no bundle key at all, so a page localised that way is fully localised. - -The coverage walk could not see it. `inlineText()` narrowed a map to `undefined` — the same value an **absent** prop produces — so one diagnostic carried two opposite facts, and the gate reported a prop written out in four languages exactly as it reports a prop nobody wrote: - -- with no bundle entry, the key was dropped from the expected set entirely: neither covered nor missing, invisible in the counts; -- with a bundle entry for one locale, the key came back with no inline evidence, and every locale the **map** held and the bundle did not was reported `missing translation` — about text that was right there in the file. - -An entry now carries a third axis beside `sourceValue` and `inline`: `inlineLocales`, the map the author wrote, verbatim. Coverage reads it per locale — a locale the map carries counts as covered, a locale it omits is reported as a gap, and the default locale is satisfied by the map the way it has always been satisfied by an inline string. The read is deliberately narrower than the renderer's: only the tag-matching limbs of the shared `resolveI18nLabel` rule count, because falling back to `en` or to the untagged `default` entry **is** what an untranslated locale looks like. - -Two things this deliberately does not do. The map is still **never extracted**: no bundle row is scaffolded for it, and no key family is added — a translator working from the locale bundle still will not find these strings, which is the cost of the form and is now stated where an author chooses it (`i18n.zod.ts`, and the extractor's own header). And no key is synthesised from a node's position in the page tree: position-addressed keys would turn a reorder of two sibling components into a silent, all-green swap of their translations. If inline maps are ever to be extracted, the recorded direction is identity first — `component.id` / `section.name` / `tabs item.value` made mandatory and gate-enforced, then the existing `pages..components..` family reused. - -Net effect on a project that authors no inline maps: none. On one that does, the gate starts telling the truth in both directions — the false `missing translation` goes, and a map that genuinely omits a locale is reported for the first time. diff --git a/.changeset/i18n-declared-fallback-chain-rest.md b/.changeset/i18n-declared-fallback-chain-rest.md deleted file mode 100644 index fb056b1e3e..0000000000 --- a/.changeset/i18n-declared-fallback-chain-rest.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): metadata label lookup honours the stack's declared `i18n.fallbackLocale` / `defaultLocale` instead of falling through to the `en` bundle (#14882) - -On a workspace whose labels are authored in `zh-CN` (`defaultLocale: 'zh-CN'`, -`fallbackLocale: 'zh-CN'`) and which ships only a courtesy `en` translation bundle, -`GET /api/v1/meta/object/:name`, the `/meta/:type` list, `GET /api/v1/meta` and the -public-form schema served the ENGLISH bundle labels to a `zh-CN` request (`Entry Sheet` -for an authored `填报单`, `KPI Assessment` for `KPI 考核管理`). The document translators walk -`requested locale → fallback chain → authored label` and default the chain to a literal -`['en']`; every REST seam passed none, so the declared fallback never reached the chain -and `en` was consulted before the authored label. - -Every metadata translation seam now passes `fallbackChain: [i18n.getFallbackLocale()]` — -the locale the i18n service's own `t()` falls back to, which `I18nServicePlugin` receives -from the stack config as `fallbackLocale || defaultLocale || 'en'`. For the workspace -above a `zh-CN` request now resolves `zh-CN → zh-CN → authored label` (the authored -Chinese labels), an `en` request still gets the `en` bundle, and a `zh-CN` bundle, when one -is shipped, still wins over the authored label. - -Feature-detected: an i18n service that does not declare a fallback (the method is -optional on `II18nService`; the core in-memory fallback has none) gets no chain and the -resolver's own default applies exactly as before. A stack declaring `defaultLocale: 'zh-CN'` -with `fallbackLocale: 'en'` is likewise unchanged — the declared `en` is honoured as it -reads. diff --git a/.changeset/i18n-declared-fallback-chain-service.md b/.changeset/i18n-declared-fallback-chain-service.md deleted file mode 100644 index 1979908b5f..0000000000 --- a/.changeset/i18n-declared-fallback-chain-service.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-i18n": minor ---- - -feat(service-i18n): `FileI18nAdapter.getFallbackLocale()` reports the `fallbackLocale` the adapter was constructed with (#14882) - -Implements the new optional `II18nService.getFallbackLocale()`. `I18nServicePlugin` -already receives `fallbackLocale || defaultLocale || 'en'` from the stack's `i18n` -config on both boot paths (`os serve`, the dev plugin); this makes that declaration -readable, so the REST metadata reads pass the document translators the same fallback -locale `t()` itself consults. Returns `undefined` when no `fallbackLocale` was given. diff --git a/.changeset/i18n-declared-fallback-chain-spec.md b/.changeset/i18n-declared-fallback-chain-spec.md deleted file mode 100644 index a030769f8b..0000000000 --- a/.changeset/i18n-declared-fallback-chain-spec.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882) - -`ResolveOptions.fallbackChain` on the `@objectstack/spec/system` label -resolvers (`translateMetadataDocument`, `translateObject`, `translateApp`, -`resolveViewLabel`, …) is the ordered list of locales consulted after the -requested one and BEFORE the authored label. Nothing on `II18nService` -exposed the deployment's declared fallback (`i18n.fallbackLocale`, else -`defaultLocale`), so no serving layer could thread it, and every caller fell -to the resolver's literal `['en']` default. A `zh-CN` workspace that shipped a -courtesy `en` bundle therefore served English bundle text to a `zh-CN` -request ahead of its own authored Chinese labels. - -- New optional contract member `II18nService.getFallbackLocale?(): string | undefined` - — the locale the service's own `t()` consults second. `undefined` (or the - method absent) means nothing was declared, and a serving layer must then - leave the resolver's default in place rather than invent a chain. -- The `fallbackChain` documentation now states who supplies it (the serving - layer, from `getFallbackLocale()`) and that the `['en']` default applies - only when a caller declares no chain at all. The resolver's behaviour for - a caller that passes nothing is unchanged. - -Additive: no existing implementation or caller changes shape. diff --git a/.changeset/i18n-extract-key-count-describes-emitted-bytes.md b/.changeset/i18n-extract-key-count-describes-emitted-bytes.md deleted file mode 100644 index 8f3d3cb6a8..0000000000 --- a/.changeset/i18n-extract-key-count-describes-emitted-bytes.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract` reports key counts that describe the bytes it emitted, and its summary is a partition of the skeleton rather than a sum over it. - -`extractTranslations` returned `counts[locale]` as a WALK counter — `count += 1` once per expected entry, unconditionally — and the command spent it as the number of keys in the file it had just written. Under the default `--objects-only` the module holds only the `objects` sub-tree, so the two are different numbers. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: - -``` - Skeleton summary - zh-CN 776 key(s) (of 776 expected) + 773 metadataForms key(s) - Wrote OUT/zh-CN.objects.generated.ts (776 keys) -``` - -The file that run wrote holds **2** leaves. The true split of the 776 is 2 objects + 1 app + 773 metadata-form baseline, so the summary appended a number the 776 already contained and read as 1549 out of 776 — an operator could not derive the truth from it, and the `(776 keys)` described no file the run produced. Both lines now read off the emitted tree: - -``` - Skeleton summary - zh-CN 775 of 776 key(s) emitted objects 2 · metadataForms 773 - Wrote OUT/zh-CN.objects.generated.ts (2 keys) - Wrote OUT/zh-CN.metadata-forms.generated.ts (773 keys) -``` - -**What each number now means.** `ExtractResult.counts[locale]` is a leaf count of `bundles[locale]` — the whole skeleton built for that locale, taken off the tree instead of off the walk that built it. It is explicitly not the size of any one file: which sections of the skeleton become committed modules is the caller's decision. The command therefore takes every count it reports off that module's own payload, selected with `translationModulePayload` — the same function `renderTranslationModule` renders from, so the number and the bytes cannot drift apart, including for a sub-tree mode added later. Nothing subtracts one count from another at a print site: that would repair today's two modes and leave the third wrong in the same way. - -**The summary line's shape changed** from `N key(s) (of N expected) + M metadataForms key(s)` to `E of S key(s) emitted` with a per-module breakdown. `E` is what this run's modules hold together and `S` is what the locale's skeleton holds, so `E ≤ S` always and the gap is exactly the keys a flag excluded — one app label under the default `--objects-only`, and nothing at all under `--no-objects-only`. A module a flag SUPPRESSED is named in the breakdown too, with its size and the words `not emitted` that keep it out of `E`: under `--no-metadata-forms` the row reads `2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`, so the operator still sees how big the baseline they switched off is — which the old, double-counting line did tell them. - -**A module with no leaves is no longer written.** The emit gate was `counts[locale] > 0`, a property of the skeleton: on a stack whose only surface is apps, the default `--objects-only` wrote a `.objects.generated.ts` holding `{}` and announced it as 774 keys. The gate is now the module's own leaf count. - -**`--json`**: `counts` is now the leaf count of the `bundles` payload printed beside it, instead of the extractor's skeleton size. The skeleton total is unchanged and still reported, under its own name, as `totalExpected`. - -⚠️ That is **not** the relationship `metadataFormsCounts` has to `metadataForms`, and nothing here changes the latter. `metadataFormsCounts` reports the baseline as BUILT, emitted or not: under `--no-metadata-forms` the payload carries `metadataFormsCounts: { 'zh-CN': 773 }` beside an empty `metadataForms`, deliberately, and a pin holds it there. So the payload carries two count semantics — `counts` is what was emitted, `metadataFormsCounts` is what was built. Both faces are unchanged by this note; it exists because an earlier draft of it claimed a symmetry that does not hold. - -**No committed bundle moves.** All nine extract configs in this repository run under the default `--objects-only` on stacks that do author objects, and every emitted module is byte-for-byte unchanged; `pnpm check:i18n` stays green on the committed tree. What changed is stdout, the `--json` counts, and the emission of a module that would have been empty. - -The regression pin spawns the real CLI in four flag states and compares each printed count against a structural leaf count of the module it wrote, parsed back off disk. That comparison is the thing the defect precluded: a walk counter cannot disagree with the walk, so no assertion over `ExtractResult` could have failed while the printed number was wrong by two orders of magnitude. Its `--json` case drives `--metadata-forms` in both states, because a case that drives one state of a flag cannot see what that flag does — driving it ON only is exactly how the symmetry claim above survived unmeasured into a first draft. diff --git a/.changeset/i18n-extract-metadata-forms-flag-independence.md b/.changeset/i18n-extract-metadata-forms-flag-independence.md deleted file mode 100644 index 2bbbc865e7..0000000000 --- a/.changeset/i18n-extract-metadata-forms-flag-independence.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract --no-metadata-forms` is honoured whatever `--objects-only` is set to, and the Studio metadata-form baseline lands in exactly one module. - -The flag gated only the `.metadata-forms.generated.ts` companion. The stack module's renderer had a third mode, `kind: 'full'`, that serialised the WHOLE `TranslationData` — the baseline included — and `--no-objects-only` selected it. So the two flags stopped being independent the moment the second one was passed, in both directions: - -- **`--no-metadata-forms --no-objects-only`** suppressed the companion and wrote the same keys into `.objects.generated.ts` instead. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: the emitted zh-CN module carried **776 leaves, of which 773 were the metadata-form baseline** the flag had just switched off (the stack's own surface is 3). Those 773 are **English** — the default locale is filled from the source labels and the metadata-form registry authors them in English — so a non-English default locale shipped the platform's English Studio strings inside its own application bundle. -- **`--no-objects-only` alone** wrote those 773 keys **twice**, once in each module. - -`--objects-only` picks the stack module's sub-tree; `--metadata-forms` decides whether the baseline is emitted at all, and it is now the only control over it **on both faces**. Both flags keep exactly the meaning their `--help` already gave them, and nothing here picks a winner between them — the overlap was in the emitter, never in the two meanings. - -`'full'` is renamed `'stack'` and omits `metadataForms`, so the module a run writes and the baseline companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776 on the fixture above — the extractor's own count, none dropped, none duplicated). ⚠️ That is a statement about the PAIR a run emits, not about "three kinds partitioning the leaves": `'objects'` is a sub-selection of `'stack'`, not a sibling of it. - -`--json`, documented as "output JSON instead of writing files", mirrors that file set: `bundles` is the stack module and a `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by the same predicate. That map is new. It exists because the first cut of this change stopped the fold on the `--json` face as well and left the baseline with no JSON home at all — measured, `--json --no-objects-only` with the flag ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`, so on that face the flag decided nothing, the mirror image of the defect this card reports. `metadataFormsCounts` reports the baseline's size in every run, as before. - -**No bundle in this repository moves.** All nine extract configs run under the default `--objects-only`, whose emitted module, export name and type signature are byte-for-byte unchanged — `pnpm check:i18n` stays green on the committed tree. A stack that DOES pass `--no-objects-only` regenerates a smaller `.objects.generated.ts`: its export keeps its name and narrows from `TranslationData` to `Omit`, and the baseline it used to duplicate is in the companion beside it unless `--no-metadata-forms` says it should not be there at all. - -**What content moves where.** On the file face nothing published loses content: under the default `--objects-only` the output is byte-identical, and under `--no-objects-only` the baseline moves out of the stack module into the companion the same command already writes — unless `--no-metadata-forms` says it should not exist, which is the ask. On the `--json` face the baseline moves from inside `bundles` to its own top-level key, and under `--no-metadata-forms` it is now absent, which it never was before: that face did not honour the flag at all. - -The regression pin spawns the real CLI and takes a group census of the bytes it wrote, and drives `--json` in BOTH flag states. The one-state version of that case could not have failed on the axis that failed here — a pin that exercises only the flag-OFF path can never detect a flag that does nothing. The sibling pin that mirrors the emit rule and checks file NAMES stayed green through all of this: the file set was right in every combination, and only the content was wrong. diff --git a/.changeset/i18n-walk-one-key-one-demand.md b/.changeset/i18n-walk-one-key-one-demand.md deleted file mode 100644 index 6255ad9acc..0000000000 --- a/.changeset/i18n-walk-one-key-one-demand.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint` and `os i18n extract` no longer count one translation key twice. - -A translation key is derived from *where a string is addressed*, not from *which declaration was being read* when the walk reached it — and two declarations can address one bundle slot. `collectExpectedEntries` emitted one entry per declaration, so a key reachable twice became two expected entries. Two families were measured, with different causes: - -- **Two carriers, one action.** The normalized config attaches an object's actions to `obj.actions` *and* to the top-level `actions` list — the same object reference, not a copy — so both action branches emitted `objects.OBJECT._actions.ACTION.*`. This is the family the coverage report shows: 70 of 691 baselined units across `app-todo` (40), `app-showcase` (29) and `app-crm` (1). -- **Two declarations, one form field.** `deleteBehavior` is declared twice in each of the `field` and `object` metadata forms, gated on `visibleWhen` (`lookup` vs `master_detail`); both render into one key. Config-independent — it duplicated six entries on every config, including an empty one. - -Neither is an authoring mistake, and neither is fixable where it originates: both are two correct declarations of one displayed string. So the walker now collapses entries that address the same path, keeping the first emission. - -What that corrects, in both directions: - -- **`os lint`'s i18n findings.** The same missing key was reported twice, byte-identically. `pnpm check:i18n-coverage` ratchets the finding *count* while its report calls the number "untranslated declared strings", so translating one key moved the ratchet by two and the frozen debt was ~11% larger than the work it described. The three coverage baselines are regenerated in this change and fall by exactly 70 (691 to 621): `app-crm` 102 to 101, `app-showcase` 443 to 414, `app-todo` 146 to 106. The ratchet's direction, monotonicity and failure text are unchanged — only the population it counts. -- **`os i18n extract`'s reported counts.** `totalExpected` and the per-locale `counts` counted emissions while the skeleton itself had already collapsed the duplicates on the way in, so extract over-reported what it wrote — 1632 claimed against 1531 keys written on `app-showcase`, 894 against 870 on `app-todo`, 930 against 925 on `app-crm`. Those numbers now match the skeleton. - -No generated bundle changes: every duplicate pair measured carries a byte-identical record, so de-duplication removes copies and never a demand. All nine `translations/*.generated.ts` packages stay in sync. diff --git a/.changeset/import-row-unique-violation-rest.md b/.changeset/import-row-unique-violation-rest.md deleted file mode 100644 index ce6d84a8b5..0000000000 --- a/.changeset/import-row-unique-violation-rest.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest)!: an import ROW report spells a unique-constraint refusal `UNIQUE_VIOLATION` — the same wire code as the whole-request failure on the same route (#14723) - - - -**BREAKING** on the per-row results of the import runner -(`POST /api/v1/data/:object/import` and the import job): a row refused by the -engine's `DuplicateRecordError` envelope now reports `code: 'UNIQUE_VIOLATION'` -where it reported `'DUPLICATE_RECORD'`. Shipped as `minor` under the repo's -launch-window convention for breaking changes. Maintainer ruling 2026-09-03 on -#14723 (verbatim 「同意,然后执行契约复审」), adopting option A: one wire -spelling for a unique-constraint refusal on every route. - -**Why.** `toFailedResult` relayed the thrown error's own `code`, and the engine's -envelope carries the registered `DUPLICATE_RECORD` — while the whole-request -failure on the same import route answered `UNIQUE_VIOLATION` through -`mapDataError`. Two spellings of one condition on one route, which ADR-0112's -one-name-per-concept and the error-code ledger's header both forbid. The -duplication is removed, not declared: no ledger waiver is added. - -**What changes.** The import row derivation applies the whole-request arm's own -predicate — the registered code AND the class name `DuplicateRecordError`, -exported from `error-response.ts` as `isEngineDuplicateRecordEnvelope` and now -shared by the arm and the row report — and reports `UNIQUE_VIOLATION`. A -field-level finding still takes precedence (the envelope carries none), the -row's sentence is unchanged (the platform sentence, sanitised as before; no -driver text), and a producer that merely throws the registered -`DUPLICATE_RECORD` without being the engine's class keeps its own code. - -**What does NOT change.** The whole-request doors (single-record, bulk, import, -metadata, UI) already answered `UNIQUE_VIOLATION` and keep doing so; the arm's -logic is untouched beyond reading the shared predicate. The engine's thrown -identity stays `DUPLICATE_RECORD` in-process. This package's `error-response.ts` -docblock that disclosed the fork under the #14541 contract review now states -the converged rule. - -**Consumer note.** An import client that branched on a row's `code` reading -`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it -already handles for the whole-request 409. Measured in-repo and in the sibling -repos (hotcrm, objectui, non-test sources): zero consumers branch on either -spelling of a row code. diff --git a/.changeset/inert-deadline-keys-retired.md b/.changeset/inert-deadline-keys-retired.md deleted file mode 100644 index decce162bc..0000000000 --- a/.changeset/inert-deadline-keys-retired.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the fourteen inert deadline keys of the incident-response, training and change-management schemas (#14477, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescriptions are -registered under protocol major 18, where `os migrate meta` users will look). -Maintainer ruling 2026-09-02 on the census card (ruled A: retire per family): -ADR-0049 enforce-or-remove decides it — declared-but-unenforced deadline -surface with zero measured readers comes off. - -Fourteen hour/minute/day-shaped deadline, SLA and duration key sites — twelve -distinct names, because `durationMinutes` and `estimatedMinutes` each occur at -two sites — sat on the exported incident-response, training and -change-management schemas and in the generated reference docs, and **nothing -read them**: the schemas are exported from `@objectstack/spec/system`, mounted -by no stack key, registered as no metadata type, absent from the 2026-06 -liveness ledgers, and the reader census over every package outside -`packages/spec` (tests and changelogs excluded) and over objectui at the -pinned sha returned zero hits for every key. An author could write -`triageDeadlineHours: 4`, `validityDays: 365` or `regulatorDeadlineHours: 72` -and reasonably expect the platform to escalate, expire or notify — it never -did, and it never said so. Six of the keys carried defaults (30 minutes, -1 hour, 2555 days; 365, 30 and 14 days) that were materialized into every -parsed document without ever being consulted. A compliance-shaped deadline -that fails silently is the worst form of the shape ADR-0049 names. - -**What is refused:** authoring any of the keys below, with any value, on the -base schema and through every carrier that nests it (`Incident.responsePhases[]`, -`IncidentResponsePolicy.notificationMatrix`, `TrainingPlan.courses[]`, -`ChangeRequest.impact` / `.rollbackPlan` / `.implementation`). None of the -schemas is `.strict()`, so each key is a `retiredKey()` tombstone rather than a -bare deletion (a deletion would have stripped it in silence): authoring it is a -`tsc` error (`never`) and a parse error carrying the prescription -(`invalid_type` at the path of the key). - -| schema | retired keys | -|:--|:--| -| `IncidentResponsePhase` | `targetHours` | -| `IncidentNotificationRule` | `withinMinutes`, `regulatorDeadlineHours` | -| `IncidentNotificationMatrix` | `escalationTimeoutMinutes` (default 30) | -| `IncidentResponsePolicy` | `triageDeadlineHours` (default 1), `retentionDays` (default 2555) | -| `TrainingCourse` | `durationMinutes`, `validityDays` | -| `TrainingPlan` | `recertificationIntervalDays` (default 365), `gracePeriodDays` (default 30), `reminderDaysBefore` (default 14) | -| `ChangeImpact` | `downtime.durationMinutes` | -| `RollbackPlan` | `steps[].estimatedMinutes` | -| `ChangeRequest` | `implementation.steps[].estimatedMinutes` | - -**What stays, byte-identical:** every other key of the three families with its -default and its (absent) readers, and every export — no def leaves the public -surface. Parsed documents no longer carry the six former defaults. - -**Held, not touched:** the `ESignatureConfig` pair (`expirationDays`, -`reminderDays` in `data/document.zod.ts`) — the ruling left that branch open -pending the e-signature roadmap answer; it stays on the card. - -## FROM → TO - -```ts -// before — parsed green; no engine ever read a single one of these numbers -const policy: IncidentResponsePolicy = { - notificationMatrix: { - rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], - withinMinutes: 15, notifyRegulators: true, regulatorDeadlineHours: 72 }], - escalationTimeoutMinutes: 45, - }, - defaultResponseTeam: 'security_team', - triageDeadlineHours: 2, - retentionDays: 3650, -}; -const course: TrainingCourse = { - id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', - category: 'security_awareness', targetRoles: ['all_employees'], - durationMinutes: 60, validityDays: 365, -}; -const rollback: RollbackPlan = { - description: 'Restore from backup', - steps: [{ order: 1, description: 'Restore backup', estimatedMinutes: 15 }], -}; - -// after — delete the keys; there is no replacement because no incident-response, -// training-management or change-management engine exists to keep a deadline. -// Record retention is the object-level `lifecycle` block (ADR-0057), declared on -// the object that stores the records. -const policy: IncidentResponsePolicy = { - notificationMatrix: { - rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], - notifyRegulators: true }], - }, - defaultResponseTeam: 'security_team', -}; -const course: TrainingCourse = { - id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', - category: 'security_awareness', targetRoles: ['all_employees'], -}; -const rollback: RollbackPlan = { - description: 'Restore from backup', - steps: [{ order: 1, description: 'Restore backup' }], -}; -``` - -One-line fix: delete the key wherever it is authored. There is no -`os migrate meta` edit list for these keys — none of the schemas is a stack -collection member, so the conversion chain has no seam to walk (the -`MetadataPluginConfig.additionalTypes` precedent); the tombstone prescription -and the protocol-18 upgrade guide are the channels. - -The retirement kit: - -- `retiredKey()` tombstones at all fourteen sites (`packages/spec/src/system/ - incident-response.zod.ts`, `training.zod.ts`, `change-management.zod.ts`; - each file's section comment records what the shape was and why no D2 - conversion exists) -- ADR-0087 registration: fourteen `RETIRED_KEYS_BY_MAJOR[18]` entries (the - three nested change-management sites spelled `ChangeImpact:downtime.durationMinutes`, - `RollbackPlan:steps.estimatedMinutes`, `ChangeRequest:implementation.steps.estimatedMinutes`) - and three D3 semantic entries, one per family -- no liveness-ledger row: none of the three families is an enrolled ledger - type, so there is no row to keep or drop -- pin tests (`deadline-keys-retirement.test.ts`): a refusal pin per site - asserting the issue path, code and prescription on the base schema and - through the nesting carriers; the tsc `never` channel; no-materialize pins - for the six former defaults; the ADR-0087 registration; and a tree-scoped - absence pin over every authored source in the repo -- generated baselines and docs follow the schema: `authorable-surface/` gains - eleven `[RETIRED]` rows, `authorable-defaults/` loses six rows, the three - system reference pages are regenerated, and the gitignored `json-schema/` - output is re-emitted on the next build -- `json-schema.manifest/` is unchanged, and correctly so: it ratchets def - *names*, and retiring keys removes no def from the published surface -- `spec-changes.json` and the protocol upgrade guide are unchanged too: both - project the migration chain at the current protocol major (17), so these - protocol-18 registrations reach them at the 18 cut -- zero authored occurrences in this repo's examples, skills and hand-written - docs, and zero hits in objectui at the pinned sha, so no in-repo source - changes ride along beyond the three families' own unit tests diff --git a/.changeset/init-generate-emit-service-object-annotation.md b/.changeset/init-generate-emit-service-object-annotation.md deleted file mode 100644 index d848afea58..0000000000 --- a/.changeset/init-generate-emit-service-object-annotation.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os init -t app`, `os init -t plugin` and `os g object` now emit an object file that compiles. All three wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`, so the first command a new user runs produced a project that failed its own `pnpm typecheck`. - -Measured against the **published** package a real user installs (`npm pack @objectstack/spec@17.3.0`, extracted and linked into a driven emission), not against the workspace: - -``` -error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object' -tsc exit 2 -``` - -Identical at TypeScript 5.3.3, 5.8.3 and 6.0.3, so it was never a compiler-version effect. `os create example` type-checked clean on the same tarball in the same run — the failure was specific to these emissions. - -The annotation is now `Data.ServiceObject`. That name was not chosen here — it is what [ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md) D1 already ruled: for a schema `XSchema`, the **bare** alias denotes the author state (`z.input`), and it is "the name documentation, examples, skills and AI authoring surfaces use for the thing an author writes". An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention — `UI.View`, `UI.Action`, `UI.Dashboard` and `Automation.Flow` are each the bare alias of their own schema — and only the object emitters had drifted off it. - -**Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout. - -The parsed-state alias is not an alternative here. Annotating the same emitted literal `Data.ServiceObjectParsed` fails all three cases with `error TS2740`, because every field literal is then missing the keys the schema supplies by default — which is exactly the author-state/parsed-state distinction ADR-0122 D2 draws. - -`content/docs/deployment/cli.mdx` taught the broken spelling too, and is corrected with them — a reader copying from the docs wrote the same uncompilable line. - -The whole emitter roster was swept rather than the three reported sites: driving every `os init` template and every `os g` generator through `tsc --noEmit` under the tsconfig the scaffolder itself writes, `Data.Object` was the only non-existent member any of them named. In particular `UI.View` and `Automation.Flow` — named alongside `Data.Object` in the docs line and explicitly not swept when this was reported — are genuinely exported, and their generators compile at exit 0. - -Why nothing caught this: both existing scaffold sweeps are runtime pins that load the emitted TypeScript through esbuild, which erases type annotations **without checking them**, so a broken annotation transpiles to byte-identical JavaScript and is invisible to them by construction. The scaffolds parsed, validated and loaded; they simply did not compile. A new pin runs the emitted projects through a real `tsc` program, with a canary that must fail with TS2694 so the harness cannot pass by resolving nothing. diff --git a/.changeset/injected-system-column-labels-localised.md b/.changeset/injected-system-column-labels-localised.md deleted file mode 100644 index 3aff300a37..0000000000 --- a/.changeset/injected-system-column-labels-localised.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did. - -`translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden. diff --git a/.changeset/install-local-admission-tenancy-posture.md b/.changeset/install-local-admission-tenancy-posture.md deleted file mode 100644 index 80a23c5216..0000000000 --- a/.changeset/install-local-admission-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -Fix: the marketplace install-local routes now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply at these doors. - -Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran here, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. - -The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage and answers 503 rather than admitting the caller. diff --git a/.changeset/job-service-replay-force.md b/.changeset/job-service-replay-force.md deleted file mode 100644 index 8d8c5aaf91..0000000000 --- a/.changeset/job-service-replay-force.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `IJobService.replay` gains an optional third argument, `options?: JobReplayOptions`, carrying `force: true` (#14766 — the contract half of the #14501 A+a2 ruling) - -Additive: the argument is optional, an existing two-argument `replay(name, data?)` implementation keeps compiling and behaving as before, and omitting it is the pre-#14766 call exactly. `JobReplayOptions` is exported from `@objectstack/spec` (`contracts`), with one member, `force?: boolean`. - -**What the contract now declares** (`packages/spec/src/contracts/job-service.ts`, the `replay` TSDoc), for a scheduled (cron) flow whose tick window takes a `(flow, tick-window)` dispatch claim in `sys_flow_dispatch`: - -- `replay(name, data)` on a window whose claim is **absent or failed** re-runs the window — unchanged behaviour, and every job that never takes a claim is this row; -- `replay(name, data)` on a window whose claim **succeeded** is **refused loudly**: the promise rejects with an ADR-0112 envelope — `code: 'RESOURCE_CONFLICT'` (the standard-catalog member HTTP 409 derives; no new extension code) and `status: 409` — whose message names the window asked for and the claim that refused it. Never a silent no-op; -- `replay(name, data, { force: true })` sends anyway; the duplicate is the operator's, taken knowingly. - -**Declared here, enforced by #14501.** This release changes the contract text and the signature only. The refusal semantics are implemented by the services half (#14501: the `(flow, tick-window)` claim through `sys_flow_dispatch`, and `DbJobAdapter.replay` reading it); until that lands, shipped adapters still accept the third argument and ignore it, re-running the window as before. A third-party `IJobService` implementation that already declares `replay` needs no change to keep compiling; one that wants the once-only guarantee implements the table above. diff --git a/.changeset/kernel-duration-keys-unit-in-key-name.md b/.changeset/kernel-duration-keys-unit-in-key-name.md deleted file mode 100644 index c8053a60c2..0000000000 --- a/.changeset/kernel-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": patch ---- - -feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) - - - -**BREAKING** — fourteen published `kernel/` duration keys are renamed and -tombstoned. Shipped as `minor` under the repo's launch-window convention for -breaking changes; the hand-migration prescriptions are registered under protocol -major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, -「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, never only in its `.describe()` prose, and grandfathers no -existing offender. Stack card 1/6 (#15676) landed the rule's two structural -exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. -Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, -and the whole-tree count falls **36 → 22**. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `EventPersistence.retention` | `retentionDays` | days | -| `EventSourcingConfig.retention` | `retentionDays` | days | -| `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | -| `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | -| `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | -| `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | -| `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | -| `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | -| `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | -| `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | -| `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | -| `StartupOptions.timeout` | `timeoutMs` | milliseconds | -| `PluginStartupResult.duration` | `durationMs` | milliseconds | -| `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | - -**Every value is unchanged** — only key names move, and every default moves with -its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to -365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the -authoring site (input type `never`) and fails the parse with the rename -prescription rather than a bare unrecognized-key error. - -## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace - -**`responseTime` meant two different units on two kernel shapes.** On -`PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a -publisher promises to answer a vulnerability report); on -`PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So -`responseTime: 24` was a day on one shape and a fortieth of a second on the -other, with nothing at the authoring site to tell them apart. They land on -`responseTimeHours` and `responseTimeMs` respectively — do not let one -find-and-replace rewrite both. - -**`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision -was already costing prose: the protocol lifecycle page carried a standing -paragraph whose only job was telling the two apart. `metrics.uptime` becomes -`metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a -separate, unchanged surface and must not be renamed with it. - -A third split worth reading before you migrate: `estimatedDurationSeconds: 120` -is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent -measurements of the same package install carried two different units, and no -parse can catch a value moved between them — both bounds accept any -non-negative integer. - -## Dispositions — five semantic entries, no D2 conversion - -Justified per key rather than defaulted, and this card's answer is uniform: -**none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs -over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, -`upgrade` or plugin-security root — none of these twelve defs is a stack -collection member or a registered metadata kind stored as a `sys_metadata` row, -so the conversion chain has no seam that would see one. They are host -construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, -`MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and -runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, -`StartupOrchestrationResult`, `UpgradePlan`, -`PackageDependencyResolutionResult`). Each therefore carries a **semantic** -entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on -one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling -B prescribes for a key that is not authorable metadata. All fourteen are -registered by exact key in `RETIRED_KEYS_BY_MAJOR`. - -## Keys deliberately left alone - -`EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and -`MultiVersionSupport.rollout.percentage` is a proportion — neither is a -duration, so neither has a unit to carry and both keep their names. -`RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above -the key ("Execution timeout in milliseconds"), a channel -`check:duration-unit-keys` does not read: it reads `.describe()` and -`.meta({ description })`, and this key's describe ("Maximum execution time") -names none. The gate therefore lists it among the duration-shaped keys but -deliberately does not judge it — neither an offender nor an exemption — so it is -outside this rename; that JSDoc-channel gap is filed as #15939. A pin test -asserts the key still parses bare, so a later sweep cannot read the four -security renames as "every timeout on that file". - -## Readers moved in the same PR, at the same magnitude - -`@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - -startTime`), the kernel and contracts test suites, and the hand-written -`content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now -states the collision the rename removes. - -⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local -`PluginStartupResult` interface — a different type, carrying `startTime` rather -than any duration key. It is not a reader of this schema, it is untouched by -this rename, and the divergence between the two shapes is tracked separately. diff --git a/.changeset/layer0-verdict-on-operation.md b/.changeset/layer0-verdict-on-operation.md deleted file mode 100644 index dec5415870..0000000000 --- a/.changeset/layer0-verdict-on-operation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/plugin-security": minor ---- - -feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall - -`BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). - -Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. - -- **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. -- **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. -- **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). - -What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. diff --git a/.changeset/layered-read-org-gate-after-fold.md b/.changeset/layered-read-org-gate-after-fold.md deleted file mode 100644 index 807403f7b9..0000000000 --- a/.changeset/layered-read-org-gate-after-fold.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`getMetaItemLayered` no longer reports a phantom org-scoped row as a tenant customization. - -`getMetaItemLayered` is the three-layer diagnostic behind Studio's "Code default vs Overlay vs Effective" view, and the third `/meta` read verb in the series `getMetaItems` (plural) and `getMetaItem` (singular) were repaired in. Unlike those two it applied no registry read gate of its own: whatever organization a caller passed was spent on whatever type it passed. On a type the registry declares `allowOrgOverride: false` — everything outside the ADR-0005 tier-A five (`view`, `dashboard`, `report`, `translation`, `email_template`) — a deployment with history can hold pre-#6190 phantom org-scoped rows, which boot hydration deliberately walks past. Read back through this verb they surfaced as `overlay` with `overlayScope: 'org'`: an operator was shown a customization that does not exist, in the one surface built to be authoritative about customizations. - -It was not only displayed. Two doors return that layer **as the response** when it is non-null — the runtime metadata dispatcher and REST `GET /meta/:type/:name/published` — so on those paths the phantom was served as the item. - -The read now resolves its organization through `organizationIdForMetaRead`, the same registry-derived predicate the REST `/meta` doors have applied since #9454 and the twin of the write side's `organizationIdForMetaWrite`. A type with a per-org read channel still resolves the caller's organization and still reports `overlayScope: 'org'`; every other type reads env-wide, which is the partition that actually runs. - -**The gate is bound after the canonical type fold, and that ordering is load-bearing.** In the two sibling verbs the binding already sat below `canonicalizeMetaRequestType`, so the fix there was a substitution. Here it sat above it, and dropping the same expression in place would have gated on the raw `/meta/:type` segment: `declaresOrgOverride` tolerates the manifest plurals but not the URL-only spellings (`translations` and `email_templates` have no manifest key), so a raw segment splits one item across two partitions, addressed by spelling. The repair is therefore a reorder, and it is pinned by a test that fails if the binding moves back above the fold. - -Callers that name no organization — four of the five `plugin-security` invocations, and every import/analytics/auth reader — are unaffected, and a door that already computed the same predicate receives the scope it did before. diff --git a/.changeset/lint-eval-generator-load-envelope.md b/.changeset/lint-eval-generator-load-envelope.md deleted file mode 100644 index b9582a48b7..0000000000 --- a/.changeset/lint-eval-generator-load-envelope.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os lint --eval --json` now carries the ADR-0112 error carriers on its generator-load failure, instead of a bare `{error}`. - -Eval mode's `--generator` load failure was the one exit on that mode with a machine face, and it was off-envelope: the `catch` built its human message and discarded the error object, so `code` and `httpStatus` could never reach the payload. A consumer that reads `code` to branch got a real code from the same command's project-lint catch-all and `undefined` from eval mode — the case a consumer is most likely to be caught by, because the face is present and looks answerable. - -The exit now spreads `errorCodeFields(error)`, the same helper the project-lint catch-all spreads, so both failure faces of `os lint` are built from one source rather than two hand-written shapes. - -Nothing is minted. `errorCodeFields` passes a producer's code through and returns nothing otherwise — ADR-0112's ledger stays the authority on who may mint a code — so the exit is polymorphic in exactly the way its sibling already is. Measured on the command's own output, across the reachable load-failure classes: - -- a generator whose top-level evaluation throws a coded failure (an SDK refusal as the module builds its client at import) now answers `{"error": …, "code": "FORBIDDEN", "httpStatus": 403}`; both keys were being dropped; -- a file the generator reads at import that is missing now answers `code: "ENOENT"`, the errno vocabulary already documented for this command, and no invented HTTP status; -- an unresolvable path or a syntax error — esbuild's own build failure, which carries neither key — still answers a bare `{error}`, as does the hand-thrown "module must default-export a function". - -The human (non-`--json`) path, the eval report exit, and offline eval are unchanged. diff --git a/.changeset/lint-eval-throwing-generator-unscorable.md b/.changeset/lint-eval-throwing-generator-unscorable.md deleted file mode 100644 index 83a6bdfb3a..0000000000 --- a/.changeset/lint-eval-throwing-generator-unscorable.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint --eval` no longer scores a failed generation as a perfect one: a generator that throws now counts 0 toward `meanScore` instead of 100. - -The harness has always handled a throwing `--generator` by substituting an empty stack and scoring that. An empty stack is **100 / grade `A` / `valid: true`** — it has nothing wrong with it because it has nothing in it. So a live eval in which every single generation failed reported the best possible headline number: - -``` -os lint --eval --json --generator ./throws.mjs -exit 1 · ok: false · passed: 0 · failed: 5 · meanScore: 100 -every case: score 100 · grade A · valid true · generationError "model unavailable" -``` - -`meanScore` is the first number a human scanning that report reads, and it read perfect precisely when the model under test produced nothing. - -**What was NOT wrong: `passed`.** It carries its own guard (`!generationError && …`), so the failed cases were reported as failed and `ok` was `false` throughout. A reader who cross-read `ok`/`passed` was safe; a reader who checked the mean and moved on got exactly the wrong impression. That is the whole defect, and nothing about `passed`, `ok`, `total`, `failed` or the exit code changes here. - -The repair is the verdict the sibling failure path already used. A generator that *returns* a value nobody can walk was already scored `0 / F / valid: false`, with the reason written into the module: a stack that cannot be walked is not an empty stack, and `valid: true` for one that was never parsed is simply false. A stack that was never produced is not an empty stack either — so both now answer the same: - -```json -{ "id": "invoice_with_line_items", - "generationError": "model unavailable", - "passed": false, - "score": { "score": 0, "grade": "F", "valid": false } } -``` - -and the run above now reports `meanScore: 0`. - -`meanScore`'s denominator is unchanged and is now stated in the payload's own documentation: the mean is over every case **attempted**, so a failed case contributes its 0 and is counted. The alternative — averaging only over cases that could be scored — is a different metric that would report the quality of the generations that arrived while staying silent about how many never did; a `meanScore` that switched denominators without saying so would be a worse defect than the one being fixed. - -No key is added to or removed from the `--json` payload, and nothing a generator can return is newly accepted or rejected: an off-shape stack is still a **scored** case whose schema errors are why it fails, never a generation error. diff --git a/.changeset/lint-eval-unscorable-stack-json-face.md b/.changeset/lint-eval-unscorable-stack-json-face.md deleted file mode 100644 index d1b95ca0f5..0000000000 --- a/.changeset/lint-eval-unscorable-stack-json-face.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint --eval --json` reports an unscorable generated stack as a failed case instead of crashing with no JSON at all. - -The eval harness promised totality in writing — *"Never throws — generation failures become failed cases"* — and the promise was false as written. Its `try` wrapped only the call to your `--generator` module; the `scoreMetadata(stack)` call that follows sat outside it. So a generator that **threw** became a failed case, exactly as documented, while a generator that **returned** a value nobody could walk took the whole process down: - -``` -os lint --eval --json --generator ./g.mjs -exit 1 · stdout 0 bytes · stderr " Error: poison getter" -``` - -A caller that asked for `--json` got the framework's human error text on stderr and no document at all to parse. Eval mode dispatches above the project-lint `try`, so the catch-all JSON exit that mode has could never see it either. - -Scoring a stack means walking it, and there are two walks: the normalizer spreads the stack's top level, and the schema parse walks everything below it. A throw from **either** now becomes that case's `generationError` — the same per-case channel a throwing generator already used — so the report exit that was always there emits its JSON, names the cause, and still exits non-zero: - -```json -{ "id": "invoice_with_line_items", - "generationError": "Failed to score the generated stack: poison getter", - "passed": false, - "score": { "score": 0, "grade": "F", "valid": false } } -``` - -Nothing new appears on the `--json` face: no new key, no new payload shape. The failing exit was already reachable for a throwing generator; it is now reachable for a poisonous one too. - -The failed case is scored `0 / F / valid: false` rather than as an empty stack. An empty stack scores 100 / A / valid, and stamping that on a stack nobody could parse would have put a clean-looking verdict next to a failure — the crash replaced by a quiet wrong answer. - -Unchanged: offline mode, and every off-shape stack a generator can return. Bad metadata is still **scored**, with its schema errors as the reason it fails — it is not rerouted into the failure channel. diff --git a/.changeset/lint-generator-requires-eval.md b/.changeset/lint-generator-requires-eval.md deleted file mode 100644 index f83be20b1a..0000000000 --- a/.changeset/lint-generator-requires-eval.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**BREAKING** `os lint --generator` now refuses to run without `--eval`, instead of accepting the flag and ignoring it. - -The flag's own description has always ended "Requires --eval.", and nothing checked it. `--generator` is read only by eval mode, so outside `--eval` the flag reached no code at all: `os lint --generator ./gen.mjs` linted the current project, exited 0 with "All checks passed", never loaded the module, and named the flag nowhere on either the human face or `--json`. A path that did not exist was accepted just as readily. Someone who meant to score a live generator got a successful-looking run whose generator was never called, with nothing said. - -The refusal is this command's own, not the argument parser's, so it keeps the shape the command's other failures already have: the reason on `error`, exit 1, and on `--json` a single JSON document with stdout still reserved for the machine. No error code is invented for it. - -A scripted invocation that passed `--generator` outside eval mode now exits 1 with the reason, where it previously exited 0 having silently skipped the generator. Eval mode itself is untouched: `--eval --generator` still loads the module and scores live output, and `--eval` alone still scores the bundled corpus offline. - - diff --git a/.changeset/lint-non-record-collection-entry.md b/.changeset/lint-non-record-collection-entry.md deleted file mode 100644 index 57da994b25..0000000000 --- a/.changeset/lint-non-record-collection-entry.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -No authoring rule throws on a non-record entry of any stack collection. - -A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape. - -Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before. - -The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked. diff --git a/.changeset/lint-non-record-objects-readers.md b/.changeset/lint-non-record-objects-readers.md deleted file mode 100644 index 81d217ab75..0000000000 --- a/.changeset/lint-non-record-objects-readers.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door - -A `null` member of `stack.objects` — what an empty YAML list item -deserialises to, and what a partial editor write leaves behind — crashed -13 of the 42 `AUTHORING_RULES` with -`TypeError: Cannot read properties of null (reading 'name')`. The -authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the -RAW `lint` path as well as the parsed one, so nothing upstream had judged -the entry's shape. At the runtime publish gate they are called inside the -gate rather than behind a try/catch of their own, so the throw was an -exception on a WRITE path, not a skipped finding; on the CLI, `os lint` / -`os validate` / `os compile` died on the first one instead of reporting -the stack. - -The repair before this one guarded ONE seam — the object-graph index every -field-path rule opens with. The crash stood at fourteen more readers of -the same collection, each a hand-copied `asArray` whose array branch was -an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was -already present in about a dozen siblings and absent in the rest, so -fixing one left the others answering the old way. - -So the copies are gone. `recordsOf` — the guarded reader, exported from -`object-graph.ts` and package-private — is now the one coercion from a -collection authored as an array OR as a name-keyed map into the records it -holds, and fifteen files call it: - -- `validate-expressions.ts`, `validate-list-view-mode.ts`, - `validate-widget-bindings.ts`, `filter-walk.ts`, - `validate-object-references.ts`, `validate-record-title.ts`, - `validate-form-layout.ts`, `lint-autonumber-formats.ts`, - `lint-view-refs.ts`, `validate-org-axis-red-lines.ts`, - `validate-sharing-rule-enforceability.ts` — the eleven sites that threw. -- `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and - `validate-page-field-bindings.ts`'s `indexObjectFields` — two shared - indexers inside the reference-integrity suite, each in front of two - rules and both hidden behind whichever suite member threw first. -- `object-field-groups.ts`'s `indexObjectFieldGroups`, which the - re-measure surfaced only once the eleven above stopped throwing. -- `validate-security-posture.ts`, the one that never threw: an `[]` - member passed its `typeof v === 'object'` read and drew a second - `security-owd-unset` at `object "(object 0)"` — an `error` about an - entry no author wrote. - -The verdict is a SKIP, not a finding, matching the seam it extends: a junk -`objects` member is a SHAPE defect and belongs to the schema, every rule -already re-answers the question in its own per-object guard, and reporting -it at the reader would emit one finding per member for one bad entry. On -the name-keyed map shape a member whose VALUE is unreadable keeps its key -(`{ name }`) — the author named it, only its body is illegible. - -No rule tier, id, message or accept-set changes. A valid object standing -beside a junk one is judged exactly as it is judged alone; only a path -index moves, and only for the rules that index `objects` raw, where -`objects[1]` is the honest position. diff --git a/.changeset/lint-readonly-create-scan-gap.md b/.changeset/lint-readonly-create-scan-gap.md deleted file mode 100644 index 0f7d3cedaa..0000000000 --- a/.changeset/lint-readonly-create-scan-gap.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`flow-update-readonly-field` and `hook-api-update-readonly-field` now report a non-system **create** of a static-`readonly` field — a new **error**-severity finding that fails `os lint` / `os validate` / `os build` on a shape they used to accept. - -Both rules scanned only the update verb (`update_record`; `ctx.api…update()` / `.updateById()`) and justified the omission with the same sentence: INSERT is engine-exempt from the author-declared `readonly` strip, so a create that seeds a `readonly` column is not a no-op. The maintainer ruling of 2026-09-03 (option C, #14147) made that false — `engine.insert` now runs the same `isSystem`-gated `stripReadonlyFields` the update path runs — so a flow `create_record` without `runAs: 'system'`, or a hook body's `ctx.api.object('…').insert()` under a non-system trigger, that writes a `readonly` field became a **silent no-op**: the row lands without the column (which falls back to its `defaultValue`), the step reports `success`, and only a run-time warning names the dropped field (measured end to end in `@objectstack/service-automation`'s `create-record-readonly-drop.test.ts`). Nothing reported it at build time. This closes that scan gap (#15394). - -**What now fails that passed before.** Exactly one new shape per rule, at `error`: - -- a flow `create_record` node whose literal `fields` map writes a field the target object declares `readonly: true`, on a flow that does not declare `runAs: 'system'`; -- an L2 hook body's literal `ctx.api.object('').insert({ … })` writing such a field, on a hook that does not declare `runAs: 'system'`. - -The rule ids and severities are the update ones — one id per shape, not per verb — and each finding's message names the verb it was judged on and what actually happens to a create. Everything the rules already skipped is still skipped: a templated object name, a non-literal payload, an object outside the stack or declaring no fields, an unknown field (the unknown-field rules' question), and any `runAs: 'system'` flow or hook, because seeding a `readonly` column at create time is a system act and that write lands. - -**Deliberately not reported.** - -- No `readonlyWhen` (conditional) finding on a create, on either surface: a conditional lock is evaluated against the record being written over, which a create does not have, and the engine runs no conditional strip on INSERT ("INSERT stays exempt"). A warning there would state something false about a write that lands. -- The hook rule judges `.insert()` only, not `.create()`. The host `ObjectRepository` aliases `create()` to `insert()`, but L2 bodies run in QuickJS and the VM-side `ctx.api.object()` installs no `create` leaf — a body calling `.create()` throws `TypeError: not a function` on its first run, a loud failure rather than the silent drop this rule reports. The silence is recorded as a reasoned method exclusion (`READONLY_HOOK_METHOD_EXCLUSIONS`) and pinned. -- No create finding on a **platform object** — one declaring `managedBy`, or in the reserved `sys_` namespace. The engine's create-side strip does not judge those at all (`staticReadonlyInsertSubject`: their own ADR-0086 write guard governs them), so a finding there would describe a strip that never runs. The update verb keeps judging them, exactly as the engine's update path does. -- `validate-readonly-action-writes` is unchanged: an action body runs system-elevated by design, so its create genuinely lands. - -**Migration.** If your build reds on the new finding, the fix is one of: declare `runAs: 'system'` on the flow or hook when seeding the `readonly` column is the intent (the intended channel — `readonly` governs the end-user/API surface, not trusted system writers); remove the key from the `create_record` `fields` / `insert()` payload when it is not; or stamp it in a `beforeInsert` hook on the target object (`ctx.input. = …`), which is a server value the strip does not touch. Measured over this repository's shipped examples (`app-crm`, `app-showcase`, `app-todo`): zero in-repo flows or hooks go red — the two `create_record` nodes that target an object carrying a `readonly` field write none of its `readonly` fields, and the one flow that creates unauthenticated already declares `runAs: 'system'`; no shipped hook body inserts through `ctx.api`. diff --git a/.changeset/list-view-grouping-server-side-contract.md b/.changeset/list-view-grouping-server-side-contract.md deleted file mode 100644 index 109fe84521..0000000000 --- a/.changeset/list-view-grouping-server-side-contract.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): list-view grouping is server-side — the group header query and the per-group row page compile from the view (#14556) - -Maintainer ruling A on objectui#7189 (2026-09-02): grouping on a list view is -server-side. The set of groups and every number in a group header — the count -and any per-group aggregation — are properties of the query, not of the fetched -page; rows inside a group are paged. Grouping one fetched window (the interim -behaviour) rendered two headers (86, 14) or five (31/31/30/7/1) for the same -186 rows in five units depending on row order, and left the rows past the -first window unreachable. - -The contract reuses the query shapes the platform already has — no new query -shape, no new engine verb, no new envelope: - -1. **The group keys and every header number are ONE aggregate query** - (`EngineAggregateOptions`, executed by `IDataEngine.aggregate`): `groupBy` - is `grouping.fields[].field` in nesting order (a multi-level grouping is a - multi-column `groupBy`), `aggregations` is a `count` node (the group's total - row count, alias `count`) plus the view's declared column summaries mapped - onto `AggregationFunction` — the one aggregation vocabulary datasets already - use — and `where` is the view's composed filter. -2. **The rows inside a group are the existing paged `find`** - (`EngineQueryOptions`) with the group's key predicate AND-ed into the view - filter, `limit` / `offset` per group. - -New on the `ui` entry, `view-grouping-query.ts`: - -- `compileListViewGroupQuery(view, { where?, depth? })` → the header query; - `compileListViewGroupRowsQuery(view, groupKey, { where?, limit?, offset?, orderBy?, fields? })` - → the row page; `listViewGroupKeyPredicate` (the empty group is spelled with - the `$null` predicate — the spelling the view filter dialect's `is_empty` - lowers to). -- `COLUMN_SUMMARY_AGGREGATION` — the `ColumnSummary` → aggregation table, - exhaustive by type: `count` → a fieldless `count` (`COUNT(*)`), - `count_unique` → `count_distinct`, `sum` / `avg` / `min` / `max` → the same - name, `none` → nothing; `count_filled` / `count_empty` / `percent_filled` / - `percent_empty` map by derivation — one `{ function: 'count', field }` node - (`COUNT(field)`, the non-null count, header column `count_`), from - which `deriveColumnSummary(row, summary, field)` computes all four on the - header row (`count_filled` = `count_`, `count_empty` = `count − - count_`, `percent_filled` = `count_ / count`, 0 when the count - is 0, `percent_empty` = `1 − percent_filled`). Server-side "empty" is `null` - on every face; the footer's client-side reading of `''` / `[]` as empty is - the renderer's to converge. A future member with no counterpart is refused - loudly at compile time (`ListViewGroupQueryError`, `NOT_IMPLEMENTED` / 501, - the summary's path — `UNMAPPED_COLUMN_SUMMARIES`, empty today); a value that - is no member at all is `INVALID_QUERY` / 400. -- Result-column naming on a header row: each grouped field under its own name - (raw stored value, `null` for the empty group; group keys are scalar), `count`, - and each summary under `_` (`columnSummaryAlias`). - -`GroupingConfigSchema` / `GroupingFieldSchema` / `ColumnSummarySchema` now say -this in their docs, with the shape's recorded limits (a date grouping field -groups per distinct stored instant; header cardinality is unbounded). Nothing -changes in what parses: no key is added, removed or re-shaped. `minor` because -a new exported helper and a declared contract semantics ship; not breaking — -the page-scoped behaviour was never declared. Both queries ride the existing -`POST /data/:object/query` door (`protocol.findData` → `engine.aggregate`, -answering `{ object, records, total, hasMore }`); the grid consuming the header -rows is objectui#7189. diff --git a/.changeset/lucky-doors-tickle.md b/.changeset/lucky-doors-tickle.md deleted file mode 100644 index 20e2591bb4..0000000000 --- a/.changeset/lucky-doors-tickle.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/objectql': patch -'@objectstack/lint': patch ---- - -Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing" - -The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations` -finding from a `type`-only test. The declarative single-record field write -(`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes -for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps -`type` at its default `script`, because the platform action route is where the -write is performed. Every such action was named at every boot and every -`metadata:reloaded` — with a prescription ("add a `body`, or register a handler -under the declared `target`") that parse itself refuses. - -Both readers now read `operation` before `type`, the precedence the runtime doors -already use: the engine inventory, and the authoring-time AI tool-reference rule, -which had diverged from the runtime's listing door and reported a resolvable -`action_` reference as fictional. diff --git a/.changeset/lucky-poems-invite.md b/.changeset/lucky-poems-invite.md deleted file mode 100644 index a4280d280a..0000000000 --- a/.changeset/lucky-poems-invite.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-hono-server": minor ---- - -fix(plugin-hono-server): the current-user faces assemble their `ExecutionContext` through the shared assembler (#15747) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). - -`makeExecutionContextResolver` is exported from this package's index. Its declared return moves **from** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise` — in practice `any`, since the exported function carried no return annotation at all and the envelope it built was a hand-rolled object literal cast `as any` — **to** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise`. `any` is assignable to everything and admits every property read, so a consumer's code really can stop compiling. - -What this asks of a consumer holding the resolver directly (the serverless host path that composes it, cloud#924): narrow the `undefined` arm before reading the envelope — under `strictNullChecks` the resolver has always been able to answer `undefined` for a request with no session, and no caller was ever asked to handle it; and stop reading members `ExecutionContext` does not declare, since the receiver is no longer `any`. A consumer that only calls `registerCurrentUserEndpoints` sees no change. - -The envelope itself is now assembled by `assembleExecutionContext` (`@objectstack/core`) — the fail-closed entry every other HTTP transport already uses — instead of the hand-rolled literal, which omitted six fields of the closed entry set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. `principalKind` is `'human'` on these faces, the value the shared assembler derives for a session-backed principal; the other five are withheld on the record. A field added to `ExecutionContext` from now on fails to compile here until this face decides it. - -No runtime behaviour changes: `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` answer byte-identical bodies, pinned as goldens. - - diff --git a/.changeset/magic-link-reads-recipient-locale.md b/.changeset/magic-link-reads-recipient-locale.md deleted file mode 100644 index d1ddb5cf4f..0000000000 --- a/.changeset/magic-link-reads-recipient-locale.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): the magic-link mail reads the recipient's own `sys_user.locale` (#15106) - -`sendMagicLink` was the last of the five auth mail sends still on the two-rung -#14319 ladder — the request's `Accept-Language`, then the deployment default. -#14762 put the recipient's stored `sys_user.locale` above both at the three -sends that hold a user row, and #14641 reached the invitation; the magic link -was fenced out because it is handed `{ email, url, token }` and no row, so the -column has to be read on the address rather than on an id. The visible cost was -one deployment answering the same person in two languages: a Chinese -password-reset mail and an English magic link, decided by whichever browser -happened to send the request. - -It now reads the column behind the existing placeholder-address refusal, in the -same shape #14641 gave the invitation send — one projected `findOne` on -`sys_user` under a system context, best-effort, and never a reason a send fails. -This completes the #14788 option-D ladder (`sys_user.locale` when set → the -request's `Accept-Language` → the deployment default) across the whole auth mail -surface: all five `sendTemplate` sites now answer per recipient. - -The request rung is kept rather than replaced. A magic link is requested BY its -recipient, so its `Accept-Language` is the recipient's own and remains a -legitimate second rung for an account that has stated no language; ruling D -inserts the column above the header, it does not remove the header. - -Two branches, because a magic link is also a sign-up: an address that carries a -row is written in that account's language, and an address with no row keeps -exactly the previous behaviour. The address is lowercased for the lookup — -better-auth applies no case transform to the magic-link request body, while -`findUserByEmail`, which `/magic-link/verify` resolves the very same link with, -matches on `email.toLowerCase()`, so the column is read for the row the link -will sign into. An address that resolves nothing lands on the rungs below, which -is the documented floor. diff --git a/.changeset/map-node-progress-state-lifetime.md b/.changeset/map-node-progress-state-lifetime.md deleted file mode 100644 index 6f841aa239..0000000000 --- a/.changeset/map-node-progress-state-lifetime.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A `map` node inside a `loop` body now runs its collection on every iteration, not just the first. - -`map` tracks its progress through the collection in the flow variable `.$mapState`, and wrote it into the flow's **shared** variable scope without ever removing it. A `loop` body region runs in that same scope by construction — that is what makes the iterator variable and the body's mutations visible to the rest of the flow — so the state written by iteration 1 was still there when iteration 2 entered the map. It read back `started === collection.length`, correctly concluded there was nothing left to start, and returned. - -The result was silent partial work reported as success: measured on the engine, **5 iterations x 2 items produced 2 child runs instead of 10**, the map step reported `success` on all five iterations, and the run finished `completed`. Nothing threw and nothing was caught, so `FlowRunSummary.failed` — the run-level counter that exists to expose contained failures — reported `failed = 0` over it. An operator reading that counter was told the run was clean while it had done a fifth of its work. - -The fix is a lifetime correction, not a new key: `$mapState` is now removed once the collection is exhausted, so its lifetime is one execution of the collection rather than the enclosing scope's. - -**The durable-pause path is deliberately unchanged.** A `map` whose per-item subflow pauses still writes its progress before suspending, and still reads it back when the engine re-enters the node — that write is the mechanism resume depends on, because a resume rebuilds the variable scope from the snapshot taken at the suspend and so can never see any later write. Only the node's terminal path clears the key. A `map` resumed mid-collection continues where it left off, exactly as before, and no item is re-run. diff --git a/.changeset/mcp-stdio-tenancy-posture.md b/.changeset/mcp-stdio-tenancy-posture.md deleted file mode 100644 index 8f4288c510..0000000000 --- a/.changeset/mcp-stdio-tenancy-posture.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -The MCP stdio transport now vets an API key's organization against the deployment's tenancy posture, instead of trusting the key's own stored claim. - -`resolveStdioExecutionContext` — the whole of this transport's authorization, since every caller on it is an API key by construction and there is no session path — built its own header map and called `resolveAuthzContext` with no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one (`organization_required` at admission, `organization_membership_ended` after grants), so a door that supplied none ran neither: the key's `sys_api_key.active_organization_id`, never re-checked against current membership, became the request's tenant. Under a wall-enforcing posture a key stamped with an organization its owner had left read and wrote that organization's rows through this door. - -The posture is now derived in the plugin's `start()`, where the kernel is reachable, and threaded into the resolver. What changes for a deployment: - -- Under `isolated` or `group`, a stdio transport configured with a key whose owner is no longer a member of the organization the key names refuses to start, and a key already live is refused on its next call. Under `isolated`, an organization-less key is refused the same way. Both refusals are logged server-side naming the key, principal, organization and reason; nothing about them reaches the caller. -- A kernel that registers no `tenancy` service is unaffected: no organization wall exists there, so no posture-conditional refusal is made. That is the supported composition, not a degraded one. -- A `tenancy` service that is registered and **fails to build** now raises `SERVICE_UNAVAILABLE` (503) rather than reading as "no posture". A posture that could not be read is not a posture that is absent, and admitting on one is the permissive-on-failure shape this repair exists to avoid. - -The posture is re-read per call, on the same schedule as the identity beside it (ADR-0101 D1), so a wall that comes up or a membership that ends mid-session takes effect on the next call rather than at the next restart. diff --git a/.changeset/membership-ended-session-revoke.md b/.changeset/membership-ended-session-revoke.md deleted file mode 100644 index c6bb0a2580..0000000000 --- a/.changeset/membership-ended-session-revoke.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/plugin-auth': minor ---- - -`sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out - -Removing a member deleted the `sys_member` row and left the session alive, for up to seven -days. #15409 closed the security half per request (a session whose `activeOrganizationId` -is not backed by a membership resolves with no active organization). This is the courtesy -half an admin was promised, and it is **never the enforcement**: a trigger can be missed, -an evaluation cannot. - -- **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening - on a published system object, hence `minor` on `@objectstack/platform-objects`. Every - reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an - interactive revoke (`user_revoked`, `admin`); this is the first authorization-event - cause. There is no Zod enum behind the column — it is free `text` — so the field's own - description is the published vocabulary, and that is where the value is declared. The - string deliberately matches the one the API-key arm of the same ruling family already - mints for this event (`authRefusal.reason` in `resolve-authz-context.ts`), so one grep - finds every place the platform acts on a membership ending. -- **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, - decision batch #49 item 4, option B). A user who still holds another membership is - **re-pointed** to it — never signed out of organizations they legitimately belong to. A - user with no remaining membership has their session revoked through the existing - `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns - nothing on the next request and the Console's existing 401 → login redirect handles it, - with **no client change**. -- **The seam is an engine hook on `sys_member`**, not a hook on better-auth's - `/organization/remove-member`. A census measured that the endpoint, a direct delete, a - bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach - the hook, while an endpoint hook would have reached one of them. Same precedent as - `last-admin-guard.ts`. -- **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, - `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence - `minor` rather than `patch`. - -Known open by measurement, not by omission: a raw driver delete bypasses the trigger -entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The -per-request check covers it; the courtesy does not. diff --git a/.changeset/memory-analytics-date-range-timezone.md b/.changeset/memory-analytics-date-range-timezone.md deleted file mode 100644 index ec09397297..0000000000 --- a/.changeset/memory-analytics-date-range-timezone.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -`driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042) - -`AnalyticsQuerySchema` declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain and writes the answer into `query.timezone` before a driver ever sees it. `parseDateRangeString()` never read it: a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was accepted, warned about nothing, and answered on the UTC day. - -⚠️ **This changes which rows a query answers for a caller already passing `timezone`.** Measured at `2026-09-06T20:00:00Z` with `timezone: 'Asia/Shanghai'`, `'today'`: - -| | window | rows selected, from the same 8 probes | -|:--|:--|:--| -| before | `[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)` — the UTC day | `06T00:00:00.000Z`, `06T15:59:59.999Z`, `06T16:00:00.000Z`, `06T23:59:59.999Z` | -| after | `[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)` — Shanghai's day | `06T16:00:00.000Z`, `06T23:59:59.999Z`, `07T04:00:00.000Z`, `07T15:59:59.999Z` | - -Four rows either way, and **two of the four are different rows**: `2026-09-06T00:00:00.000Z` and `2026-09-06T15:59:59.999Z` leave the answer (they are yesterday in Shanghai), `2026-09-07T04:00:00.000Z` and `2026-09-07T15:59:59.999Z` join it (they are today in Shanghai). Both row sets are asserted against the real `MemoryAnalyticsService.query()` entry, in the same test, so the before is measured rather than recalled. - -A query carrying **no** timezone is byte-identical to before: `zonedDateStartToUtcMs` returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so #15825's repair — the common case — is untouched, and both of its pins stay green. - -Two halves, each of which fails silently on its own and each of which is pinned: the reference timezone decides **which** calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern), and **where that day begins as an instant** (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Resolving only the first would anchor to the zone's calendar day and then cut it at UTC midnight — a window that is neither the UTC day nor the zone's. The end bound is a calendar step, never `+ 86_400_000`: on `America/New_York`, 2026-03-08 is 23 hours long. diff --git a/.changeset/memory-analytics-date-range-utc-window.md b/.changeset/memory-analytics-date-range-utc-window.md deleted file mode 100644 index 11a57cf6c3..0000000000 --- a/.changeset/memory-analytics-date-range-utc-window.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -`driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825) - -`MemoryAnalyticsService.query()` lowers a string `dateRange` through -`parseDateRangeString()`, and that function built its window on the **local** -calendar and rendered it as **UTC**. Two independent defects lived in it. - -**1. The window boundary was local midnight.** `new Date(y, m, d)` constructs -local midnight; `toISOString()` renders that instant in UTC. So in any process -not sitting at UTC, the `'today'` bucket was the **local** day expressed as a -UTC range. Measured 2026-09-05, with the clock at `2026-09-05T20:51Z`: - -| `TZ` | `'today'` window produced | the UTC day it should be | -|:---|:---|:---| -| `UTC` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | (agrees) | -| `Asia/Shanghai` | `2026-09-05T16:00Z` → `2026-09-06T16:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | -| `America/Los_Angeles` | `2026-09-05T07:00Z` → `2026-09-06T07:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | -| `Europe/Berlin` | `2026-09-04T22:00Z` → `2026-09-05T22:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | -| `Asia/Kolkata` | `2026-09-05T18:30Z` → `2026-09-06T18:30Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | - -That is wrong on **every day of the year**, with no DST transition needed. - -**2. The `last N ...` legs mixed two calendars.** `setDate(getDate() - n)` is -local arithmetic and `toISOString()` is a UTC rendering. `setDate` preserves -wall-clock time, so the instant moves `n × 24h` only while every local day in -the window is 24 hours long; across a DST transition it moves 23h or 25h and -the window start slips an hour. `setMonth` / `setFullYear` are the same class, -and can move it by a whole day: at `America/New_York` with the clock at -`2026-01-01T12:00Z`, `last 1 month` started at `2025-12-02T00:00Z` instead of -`2025-12-01T00:00Z`. - -**⛔ The two do not fix each other**, which is the easiest thing to get wrong -here: `setUTCDate` alone leaves the local-midnight boundary in place, and -`Date.UTC` alone leaves the arithmetic mixed. Both are repaired, each is pinned -by its own file, and each was ablated on its own to prove the separation. - -**Why UTC and not "any consistent calendar".** The rest of the platform -resolves a bare date to the UTC day — `@objectstack/core`'s `{today}` -filter-token macro builds its reference day as -`new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts when the -context carries no timezone, and `{TODAY()}` in flow templates resolves to the -UTC day (#14852 repaired the identical two-calendar shape there). Before this -change the same analytics question asked through the driver's `dateRange` and -through a flow token could select **different rows in one deployment**. UTC is -also the terminal fallback of the engine's own resolution chain -(`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2). - -**What did not change.** The parser's vocabulary, its `[range, range]` -fallback, and the shape of the emitted `$match` are untouched — this is a -calendar repair, not a rewrite. `AnalyticsQuery.timezone` is still not consulted -by this path; making the range tokens timezone-**aware** is a separate and -larger question, which #14852 also declined. - -**Who sees a difference.** Any deployment whose process is not at UTC: `'today'` -and `last N ...` now select the UTC day they always claimed to, so charts built -on a string `dateRange` shift by the process offset — toward agreement with -`{today}` / `{TODAY()}` and with the same query run at `TZ=UTC`. Deployments -already running at UTC are unaffected; the two spellings are indistinguishable -there, which is exactly why CI never reddened on this. diff --git a/.changeset/memory-i18n-declared-fallback-locale.md b/.changeset/memory-i18n-declared-fallback-locale.md deleted file mode 100644 index ab7d855cee..0000000000 --- a/.changeset/memory-i18n-declared-fallback-locale.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor -"@objectstack/runtime": minor ---- - -The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) - -`i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. - -The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. - -What changed: - -- **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. -- **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. -- **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. - -A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. - -`getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. diff --git a/.changeset/metadata-ambiguous-stem-refused.md b/.changeset/metadata-ambiguous-stem-refused.md deleted file mode 100644 index 33443247db..0000000000 --- a/.changeset/metadata-ambiguous-stem-refused.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -fix(metadata): two files sharing one stem are refused with both paths named, instead of one being listed twice and served by extension precedence (#14921) - -**BREAKING** accept-set narrowing on `FilesystemLoader`, shipped as `minor` -under the repo's launch-window convention for breaking changes. Ruled on -#14921 (2026-09-05, option 1 of three). - -**Remedy: delete or rename the duplicate file.** The refusal names every -colliding path and the metadata type, so the fix is visible at the point of -failure. - -`FilesystemLoader` derives a metadata name by stripping a flat file's -extension, and resolves a name back to a file under a FIXED extension -precedence (`.json` → `.yaml` → `.yml` → `.ts` → `.js`). Two files sharing a -stem therefore produced one name **twice** in `list()` while only the -first-precedence file was reachable through any name at all. With -`object/twin.json` and `object/twin.yaml` both present, `list()` answered -`['twin', 'twin']`, `twin.yaml` was addressable through nothing, and -`loadMany()` returned both bodies. `MetadataManager.listNames()` unions loader -output into a `Set`, which collapsed the duplicate and took the count -discrepancy with it — the file stayed unreachable either way, so a clean -`listNames()` was never evidence the collision had been absorbed. - -The invariant that broke: **what is listed is what is loadable.** The listed -set and the addressable set stopped being the same set. The failure was silent -in the direction that matters for authoring — convert `twin.json` to -`twin.yaml` and leave the old file behind, or land one from each of two -packages, and the JSON one is served forever with no diagnostic anywhere, -while `admitLoaderItems()`'s documented "keep the first and say nothing" -absorbs the collision a second time. - -`FilesystemLoader.list()` now throws `AmbiguousMetadataStemError` -(`AMBIGUOUS_METADATA_STEM`, HTTP 500) naming both paths and the type, and the -same refusal fronts the shared `loadMany()` / `loadManyKeyed()` walk, so the -two-body answer is gone rather than de-duplicated. `MetadataManager.listNames()` -and `list()` **propagate** it rather than absorbing it into their per-loader -degradation: an ambiguous stem is an authoring error no retry fixes, and -degrading it would drop every item the loader holds into a short-but-served -list while the server keeps reporting healthy. A real storage outage still -degrades exactly as before — the seams discriminate on a branded predicate, -`isAmbiguousMetadataStemError`, not on a blanket rethrow. - -**Refused shape**, precisely: two or more files **directly under -`ROOT/TYPE/`** whose basenames differ only by an extension belonging to one of -**this instance's registered serializers**. Register `javascript` and -`dual.json` + `dual.js` becomes ambiguous; under the manager's default format -set (`typescript` / `json` / `yaml`) it is not, because `.js` derives no name. -Nested files are untouched — they are neither listed nor resolvable (#14486), -so `crm/solo.json` beside a flat `solo.json` is not a collision. The refusal is -scoped to the type directory that holds it: a clean `view/` still lists while -`object/` refuses. - -New exports from the package root entry: `AmbiguousMetadataStemError`, -`isAmbiguousMetadataStemError`, `AMBIGUOUS_METADATA_STEM_CODE`, -`AMBIGUOUS_METADATA_STEM_STATUS`. - -Measured migration cost, which is what makes this narrowing cheap: **no tree in -this repository carries the shape.** A walk of all 7,770 tracked files across -526 directories found zero stem collisions among `.json` / `.yaml` / `.yml` / -`.ts` / `.js`, confirmed independently by a `git ls-files` pass, and the repo -holds no `.yaml`/`.yml` metadata file at all outside CI and workspace config. -No existing tree goes red. - - diff --git a/.changeset/metadata-database-loader-ttl-ms.md b/.changeset/metadata-database-loader-ttl-ms.md deleted file mode 100644 index 75b3841a93..0000000000 --- a/.changeset/metadata-database-loader-ttl-ms.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478) - - - -**BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape -(`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the -launch-window convention. `MetadataManager` hands `config.cache.databaseLoader` -straight to `new DatabaseLoader({ cache })`, so this option is the spec key -`cache.databaseLoader.ttlMs` one layer down and renames with it: a loader -configured with `ttlMs: 60_000` expires entries after 60 seconds exactly as -`ttl: 60_000` did. The README example and the kernel metadata-service docs page -spell the new key. - -```ts -// before -new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttl: 60_000 } }); -// after -new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttlMs: 60_000 } }); -``` diff --git a/.changeset/metadata-view-container-leaf-subpath.md b/.changeset/metadata-view-container-leaf-subpath.md deleted file mode 100644 index ca89281928..0000000000 --- a/.changeset/metadata-view-container-leaf-subpath.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/objectql': patch ---- - -feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function - -`packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through -`@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — -re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure -inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> -`chokidar`, plus `glob`, `js-yaml` and `readdirp`. - -The same file already carried the answer 79 lines above, at its -`@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a -cross-package consumer gets the predicate without the manager, the loaders or -the YAML/filesystem machinery behind the root entry". This is that pattern, -taken a second time. - -**Measured on the built artifacts, not asserted** — every module Node actually -evaluates when `@objectstack/objectql/core` is loaded in a fresh process, -recorded through a `module.registerHooks` load hook (ESM and CJS) plus -`require.cache`, byte sizes from `statSync`: - -| `@objectstack/objectql/core` | modules | bytes | -|:---|---:|---:| -| before (ESM `dist/core.mjs`) | 190 | 12,348,424 | -| after (ESM `dist/core.mjs`) | 185 | 11,849,808 | -| **delta** | **-5** | **-498,616 (-486.9 KiB)** | -| before (CJS `dist/core.js`) | 188 | 12,654,238 | -| after (CJS `dist/core.js`) | 183 | 12,141,034 | -| **delta** | **-5** | **-513,204 (-501.2 KiB)** | - -Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), -`js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and -`readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal -module-init time for that root entry, measured on a warm lean closure, was -~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. - -⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about -the delta and wrong about the baseline: the lean entry's closure was already -~11.5 MiB before this import existed, dominated by `@objectstack/spec` -(9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry -contributes. What the root import cost was ~487 KiB *on top of* that, not a -closure of 450 KB. - -The derivation itself moves to `packages/metadata/src/view-container.ts`, a -module with **no imports at all**, and `view-container-expansion.ts` imports -and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their -spelling and the symbol stays on the root entry — this subpath is an additional -door, not a relocation. A re-export shim onto `view-container-expansion.ts` was -tried first and rejected on measurement: esbuild tree-shakes the unused -`expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import -statements, so that shim's own closure was 84 modules / 3,035 KiB. The real -leaf's is 1 module / 469 B. - -`expandRuntimeViewContainer` is deliberately not exported from the new subpath: -`metadata-manager.ts` is its only caller, the root entry does not export it -either, and it is the half that carries the spec machinery. diff --git a/.changeset/migrate-meta-chain-line-protocol-label.md b/.changeset/migrate-meta-chain-line-protocol-label.md deleted file mode 100644 index ea6d28af3d..0000000000 --- a/.changeset/migrate-meta-chain-line-protocol-label.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os migrate meta` no longer prints the protocol version under the word "runtime", where it read as the installed package version. - -The chain line used to end `(runtime 17.0.0)`. That number is `PROTOCOL_VERSION` — the protocol major padded to a semver — and it is not, and never tracks, the version of the installed `@objectstack/cli` or `@objectstack/spec`. On a 17.3.0 install the line appeared beside the real package versions of the same upgrade session (`npm view`, the changelog), so it read as "your runtime is 17.0.0": an apparent downgrade or a stale install, neither of which was true. - -The value was never wrong — the label and the semver form were. The line now states the fact in the protocol's own units: - -``` -Chain: protocol 17 → 17 (this runtime implements protocol 17) -``` - -The parenthetical is relabelled rather than dropped, because it carries a fact nothing else on screen does: when `--to` stops below this build's major, it is the only place the operator is told where the runtime actually stands (`Chain: protocol 16 → 16 (this runtime implements protocol 17)`). - -The `--json` payload is deliberately untouched: its `runtime` key still carries the same padded protocol semver. Renaming a machine-readable key is a contract change owing a reader census and a deprecation window of its own, and it is tracked separately — an e2e pin now asserts the key's current value so that move cannot happen silently. diff --git a/.changeset/notification-event-migration-ledger-claims.md b/.changeset/notification-event-migration-ledger-claims.md deleted file mode 100644 index 4a107de6f6..0000000000 --- a/.changeset/notification-event-migration-ledger-claims.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `adr-0030-notification-event` joins `CREATION_ATTESTED_MIGRATION_IDS`, and its docblock states what a run may claim in the `sys_migration` ledger (maintainer ruling 2026-09-05 on #15710) - -The ADR-0030 notification-convergence migration id was registered so that -"has this cut-over run here?" is answerable at all, with its ledger semantics -deliberately left open on the constant. The maintainer has now ruled them -(decision batch #47 item 5, verbatim 「同意」 — the question batch #21 reserved), -and this release lands the spec half: - -- **Creation-attested.** A datastore created after the cut-over has no legacy - `sys_notification` inbox rows by construction, so the id is now a member of - `CREATION_ATTESTED_MIGRATION_IDS`. A store created from empty on this release - therefore carries a third attestation row in `sys_migration` at boot, in the - same uniform shape as the two ADR-0104 rows (`details.attested: - 'datastore-created-empty'`, `applied_at: null`, `blocking: 0`, `verified_at` - set for the fact observed at birth). Existing stores are untouched: - `attestFreshDatastore` writes only on a store it observed being created and - never overwrites a row, so a store created before this release attests - nothing new — its row for this id arrives with the first run of the migration. -- **The ledger-claim matrix**, on the constant's docblock, replacing the - registration-era "silence is not an answer": `last_run_at` on every completed - non-`error` run (`migrated`, `already_done`, `not_applicable`); `applied_at` - only on `migrated`; `verified_at` never set by a run (the migration has no - self-check, and `verified_at` means one passed); `blocking: 0`; - `details.outcome` carries the four-valued result; an `error` run writes no - claim at all. -- **Receipt, not gate.** Nothing reads the row as a precondition, and nothing - may: it is what an operator reads, in the shape the seed-tenancy repair - already uses (`verified_at: null`, `blocking: 0`), which - `isDataMigrationFlagVerified` answers `false` to by design. - -Additive: no authorable key, export or accept-set narrows, so no BREAKING -banner applies. Which caller writes the run receipt when the migration runs is -the runner's own contract (`@objectstack/metadata/migrations`) and lands -separately. diff --git a/.changeset/notification-event-migration-run-receipt.md b/.changeset/notification-event-migration-run-receipt.md deleted file mode 100644 index e2c05326b8..0000000000 --- a/.changeset/notification-event-migration-run-receipt.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -A completed run of the ADR-0030 notification cut-over now records itself in the `sys_migration` deployment ledger, per the ruled claim matrix. - -`migrateSysNotificationToEvent` reports `migrated` / `already_done` / `not_applicable` / `error` to its caller and — until now — recorded nothing anywhere. Once that line had scrolled, "did this cut-over run here, and when" had no answer in the deployment even in principle. The ledger row is what answers it, and what a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` is stated on that constant in `@objectstack/spec/system`: - -- `last_run_at` — stamped on every completed non-`error` run (`migrated`, `already_done`, `not_applicable` alike). -- `applied_at` — stamped only on `migrated`. Never cleared: a later `already_done` leaves an earlier backfill's stamp alone, because the backfill really did happen. -- `verified_at` — never written, in either direction. This migration has no self-check, and `verified_at` means a self-check passed. On a store created after the cut-over the row already exists and `attestFreshDatastore` set `verified_at` at birth; that certificate survives a run untouched, because the column is omitted from the update rather than sent as `null`. -- `blocking: 0`, and `details` carrying `{ outcome }` verbatim. -- An `error` run writes no claim at all — it does not know what it did, so it does not say. - -**Receipt, not gate.** Nothing reads a row under this id as a precondition and nothing may: a gate would need the self-check that does not exist. The row is what an operator reads, in the shape `sys_migration` already documents for the seed-tenancy repair. - -Two additions to the published surface of `@objectstack/metadata/migrations`, both driven by that: a new `SysNotificationMigrationReceipt` type, and a new `receipt` member on `SysNotificationMigrationResult` reporting what became of the claim (`inserted` / `updated` / `not-claimed` / `no-ledger` / `failed`, with a reason on the last two). This directory takes no logger and reports to its caller, so the claim's own fate is reported the same way the migration's is — a receipt that could not be written is never swallowed. Reading a result is unaffected; code that CONSTRUCTS a `SysNotificationMigrationResult` by hand (a test double) now supplies `receipt`. diff --git a/.changeset/object-door-searchable-listview-refusal.md b/.changeset/object-door-searchable-listview-refusal.md deleted file mode 100644 index 3dd84b8d08..0000000000 --- a/.changeset/object-door-searchable-listview-refusal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. - -`#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short. - -- **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is. -- **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot. -- **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused. -- **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication. - -## Migration - -**A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has. - -To fix a refusal, do one of: - -- rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or -- drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one. - -`os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them. diff --git a/.changeset/object-graph-null-entry-guard.md b/.changeset/object-graph-null-entry-guard.md deleted file mode 100644 index 851c7d6b73..0000000000 --- a/.changeset/object-graph-null-entry-guard.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/lint': patch -'@objectstack/metadata-protocol': patch ---- - -A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong". - -`indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before. - -On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify. diff --git a/.changeset/objectql-boot-loop-refuses-divergent-view-name.md b/.changeset/objectql-boot-loop-refuses-divergent-view-name.md deleted file mode 100644 index 6b07355ea3..0000000000 --- a/.changeset/objectql-boot-loop-refuses-divergent-view-name.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql): the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666) - -**BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar -(`registerMetadataCollections`), shipped as `minor` under the repo's -launch-window convention for breaking changes. Ruled on #14666 (2026-09-03, -direction 2). - -An aggregated `defineView` container is keyed by the OBJECT it binds to, not -by its own row identity, and `ViewSchema` declares an optional `name` whose -own description says that for an object-scoped container it *is* the object -name. Nothing enforced that. A container written as -`{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached -the two SOURCE registrars and got opposite answers: this boot loop overwrote -`name` with the derived key `crm_lead` and registered it, discarding the -author's field with no diagnostic, while the artifact/HMR loader -(`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load -through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` / -400). Same document, and whether it loaded at all depended on how the package -was loaded. - -The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400 -envelope the artifact door raises, naming the container's own `name`, the -object key it derived, and both remedies: drop `name`, or set it to that -derived key. #7378 row 1 already ruled that resolving such a disagreement -silently, in either direction, files the item under a key the caller never -wrote, so the two registrars converge on the refusal rather than on the -rewrite; the artifact door is unchanged. - -**Refused shape**, precisely: an aggregated view container in a stack `views:` -collection that carries a non-empty top-level `name` AND derives a different -object key from its own `object` (or, failing that, `list.data.object` / -`form.data.object`). - -Scope, which the ruling names as this change's main risk. A container with no -`name` is untouched, and still registers under its derived key. So is a -container whose `name` already equals that key, and one that declares no -binding anywhere else, since the derivation then falls back to that same -`name` and cannot disagree with itself. No other metadata kind changes -behaviour: the refusal is gated inside the `views` branch of the generic -registration loop. Standalone ViewItems and flattened overlays travelling in -the assembled `viewItems:` channel are untouched, because a container cannot -reach that channel at all. Every one of these has a control test. - - diff --git a/.changeset/objectql-hook-timeout-ms.md b/.changeset/objectql-hook-timeout-ms.md deleted file mode 100644 index 90d631ae61..0000000000 --- a/.changeset/objectql-hook-timeout-ms.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the declarative hook wrapper reads the renamed `hook.timeoutMs` (#14478) - -`wrapDeclarativeHook` reads its wall-clock abort budget from `meta.timeoutMs` -instead of `meta.timeout`, following the `@objectstack/spec` rename of the -authored key (the unit now lives in the key name). Same value, same magnitude, -same abort; no public surface of this package changes. diff --git a/.changeset/objectql-system-write-organization-recognizer.md b/.changeset/objectql-system-write-organization-recognizer.md deleted file mode 100644 index 74db9a4109..0000000000 --- a/.changeset/objectql-system-write-organization-recognizer.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -`@objectstack/objectql` now publishes a recognizer for the org-less system-write refusal, so a consumer no longer has to choose between an unsound check and a re-spelled string. - -`SystemWriteOrganizationRequiredError` has always documented that it is identified by `code` rather than `instanceof`, "so the check survives crossing a package boundary where two copies of this module can exist". The convention was correct; the affordance for following it was missing. This package declares **both** realms in its own `exports` — `import` to `dist/index.mjs`, `require` to `dist/index.js` — so a consumer that loads it through the other realm than the engine did holds a second copy of the module. Measured across that split from a real consumer package: same class identity (`A === B`) **false**, `instA instanceof A` within one realm **true**, `instA instanceof B` across the two **false**, and a `code` compare **true**. So `instanceof` against this class was unsound for every consumer, and it failed silently — a `catch` that simply never fires. - -That left a consumer with one sound option: re-spelling `'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'` as a literal. That spelling is what `check:error-code-provenance` counts as a stamp site, so recognising one engine refusal cost the consumer's package a provenance decision of its own, and left the string spelled in two places with the typo failure mode standing — a typo in a `catch` produces a branch that never fires rather than an error. - -Two new exports close it, both from the package root: - -- **`SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`** — the code as a value. Same shape as this package's five existing published codes (`DUPLICATE_RECORD_CODE`, `HOOK_TARGET_REBIND_ERROR_CODE`, `HOOK_UNSCOPED_DATA_ACCESS_CODE`, `MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE`, `EMPTY_CREDENTIAL_REFUSAL_CODE`) rather than a new abstraction. The class field now reads from it, so exactly one spelling of the string remains in the package and a typo at an import site is a compile error instead of a dead branch. -- **`isSystemWriteOrganizationRequiredError(err): boolean`** — the code compare itself, so a consumer performs the sound check without authoring the string at all. - -The predicate deliberately returns `boolean` and does **not** narrow to `err is SystemWriteOrganizationRequiredError`. A `code` compare is satisfied by any value carrying that code, including an envelope a transport rebuilt from the wire — #5437 withholds the prose and keeps the machine-readable code — so a type guard would promise `object`, `posture` and `reason` members such a value need not have, moving the unsoundness one layer down instead of removing it. - -⛔ Nothing about the refusal itself changes: not its `code`, not its 500 status, not when it fires, and not the #8844 `derive-or-refuse` ruling behind it. `SystemWriteOrganizationRequiredError['code']` stays the literal type it was, which is what the existing cross-package consumer types its own constant from. This is purely an addition to what the package publishes. diff --git a/.changeset/olive-spiders-refuse.md b/.changeset/olive-spiders-refuse.md deleted file mode 100644 index cf9e7d95c0..0000000000 --- a/.changeset/olive-spiders-refuse.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**BREAKING** `os create ` now refuses a project name that npm refuses, and refuses it before it writes anything. - -`os create plugin "My App"` used to exit 0 having written `./plugin-My App/`, carrying a manifest that read `name: "@objectstack/plugin-My App"`. Nothing failed at scaffold time, so the invalid name surfaced later at `npm publish`, in the terminal of whoever ran it next. `os init` has always refused that same input before touching the disk. The rule set is now shared between the two scaffolders rather than restated in one of them, so they answer the same way. - -`os create` also refuses a name whose composed scoped package name exceeds npm's 214-character ceiling. `@objectstack/plugin-` spends 20 of those characters before the name begins, so a name that `os init` accepts can still compose to one npm rejects; that check sits next to the composition rather than in the shared rule set. - -A scripted invocation that passed an invalid name now exits 1 with the reason on stderr, where it previously exited 0 and produced a project that could not be published. - - diff --git a/.changeset/org-hierarchy-timezone-columns.md b/.changeset/org-hierarchy-timezone-columns.md deleted file mode 100644 index 8c1144266e..0000000000 --- a/.changeset/org-hierarchy-timezone-columns.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/plugin-auth': minor ---- - -feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) - - - -Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. - -**The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. - -**What lands.** - -- `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. -- `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. -- plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. - -**The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. - -**Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. - -**One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. - -**Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. diff --git a/.changeset/os-create-emits-an-installable-project.md b/.changeset/os-create-emits-an-installable-project.md deleted file mode 100644 index f4da992bd5..0000000000 --- a/.changeset/os-create-emits-an-installable-project.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os create` now emits a project that installs outside this monorepo. - -Every project the command scaffolded declared its `@objectstack/*` dependencies -with pnpm's `workspace:*` protocol, extended a `tsconfig.json` two directories -above itself, and was written into this repository's own `packages/plugins/` or -`examples/` by default — so a developer following the documented command got a -project `pnpm install` refuses. The default emission is now standalone: - -- `@objectstack/*` dependencies are published semver ranges pinned to the - version of the CLI that generated them; -- the emitted `tsconfig.json` is self-contained and extends nothing; -- the project is written to `./` in the current directory (or `--dir`); -- a `pnpm-workspace.yaml` carries the build approvals a fresh `pnpm install` - needs on pnpm 11. - -The `plugin` template also emits `init` where it used to emit `initialize`. -`initialize` is not part of the `Plugin` contract, so the scaffold did not -type-check under its own `strict` config (TS7006 on the untyped `context` -parameter) and `kernel.use()` refused the plugin at load with -`Plugin init function is required` — a defect the kernel protocol docs -previously carried a warning about instead of a fix. - -The previous monorepo-internal placement is still available for ObjectStack -platform work as the explicit `--in-repo` flag, which keeps the `workspace:*` -specs and writes into `packages/plugins/` or `examples/`. diff --git a/.changeset/plain-unique-index-duplicate-preflight.md b/.changeset/plain-unique-index-duplicate-preflight.md deleted file mode 100644 index 174193bede..0000000000 --- a/.changeset/plain-unique-index-duplicate-preflight.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -A plain unique index over existing duplicate rows no longer kills the boot with the database's raw error, and `os migrate plan` no longer calls that op `safe`. - -Declaring a column unique over a table that already holds duplicates had two very different outcomes depending on one branch in the SQL driver, and only one of them was survivable. - -- **An organization-scoped unique** (the `unique: 'organization'` default, materialised as the NULL-safe `COALESCE(organization_id, '__global__')` composite) kept the boot up: the driver logged at `error` naming the index, the constraint that is not enforced and the remedy, and the ADR-0120 D4 duplicate pre-flight reported the blocked `create_index` as `category: 'destructive'` / `severity: 'error'` with the conflicting key groups and their row counts. -- **A plain unique** — no organization key part at all, reached by an object with `tenancy: { enabled: false }` or by any explicit `unique: 'global'` — took the process down: `initObjects` threw the database's own error, which names the index and the column and no rows and no remedy, nothing reached the durability channel, and `detectManagedDrift` (what `os migrate plan` reports) classified the very same op `category: 'safe'`, `severity: 'warning'`, so `os migrate apply` and dev `autoMigrate: 'safe'` walked straight into the raw failure. - -The plain path now reaches the same posture as the scoped one: - -- **The boot survives and says what is not enforced.** `syncDeclaredIndexes` absorbs a uniqueness violation on a plain unique index the way it already absorbed one on the NULL-safe composite: the failure is logged on the durability channel (`error`) naming the index, the conflicting key groups with their row counts, the constraint that is NOT enforced, and `os migrate plan` as the way out. A non-unique index and any failure that is not a uniqueness violation still surface as before. -- **The duplicate pre-flight covers it.** The ADR-0120 D4 probe no longer skips ops whose NULL-safe column set is empty, so a plain unique `create_index` over dirty data is reported `destructive` / `error` with the same row report instead of `safe`. Nothing new probes it: the existing probe already groups by the bare columns when there is no NULL-safe key part, so both key shapes share one pre-flight rather than a second copy that can drift from the first. - -Consumers of the classification see the op move from the "Safe" group to "Destructive (requires --allow-destructive)" in `os migrate plan` and `os diff`; `os migrate apply` defers it instead of attempting it; the artifact boot gate refuses with a named destructive-drift refusal instead of crashing; and dev `autoMigrate: 'safe'` leaves it alone. Clean data is unaffected — the probe finds nothing and the index is created exactly as before. diff --git a/.changeset/platform-object-tenancy-census-derived.md b/.changeset/platform-object-tenancy-census-derived.md deleted file mode 100644 index 7855601bcb..0000000000 --- a/.changeset/platform-object-tenancy-census-derived.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -The platform-object tenancy census is derived and gated instead of hand-written in a comment. Documentation only — no runtime behaviour changes. - -`PLATFORM_OBJECT_TENANCY`'s header explained why the reclassification needs a ledger rather than a schema read, and backed the argument with three hand-written digits and a parenthetical attributing them. Nothing re-derived any of it, so it was true only until the population moved and failed silently when it did — in both of the directions a prose count can. - -The parenthetical mis-attributed the exclusion: it named `sys_sso_provider`'s `tenancy.enabled: false` as an addition to the `managedBy: 'better-auth'` set that object was already in, and left `sys_api_key`'s identical opt-out unnamed. The arithmetic stayed right, which is why no reader and no gate caught it — a wrong reason producing a right total is the shape that survives longest. The digits then went stale when an object opted out of the tenant column through a third mechanism the parenthetical's taxonomy had no slot for (`systemFields: { tenant: false }`), while the gated page next door was updated in the same commit. - -The digits and the parenthetical are deleted rather than corrected. The header now points at `scripts/platform-object-tenancy-census.json` and states the PREDICATE it was missing: an object is inside the machinery when `resolveTenantFieldName` answers non-null on the **registered** schema — after `applySystemFields` has injected the tenant column, because the injected column is what the engine sees, not what the author typed. Counting `managedBy` as if the resolver read it is the mistake that produced the wrong reason. - -The artefact is derived by `scripts/platform-object-tenancy-census.mjs`, which loads `resolveTenantFieldName` and `resolveInjectedSystemColumns` from source and executes them rather than re-spelling what they decide, and is held to the tree by `scripts/check-platform-object-tenancy-census.mjs`. It records per object the declaration on that object's own schema that puts it outside the reach; declarations are not mutually exclusive and an object carrying two keeps both. An excluded object with no declared mechanism is an error, not a default: the generator refuses to commit the row and the gate reds, so a new exclusion mechanism is adjudicated rather than absorbed into an existing total. diff --git a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md deleted file mode 100644 index 6b6b872d5c..0000000000 --- a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) - -The `metadataForms.dashboard` translation bundles key the auto-refresh field as -`refreshIntervalSeconds`, following the `@objectstack/spec` rename of the -authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the -hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across -the rename unchanged, because the field still means what it meant and each help -text already named the unit. diff --git a/.changeset/plugin-auth-find-envelope-limbs.md b/.changeset/plugin-auth-find-envelope-limbs.md deleted file mode 100644 index 2eeba4b964..0000000000 --- a/.changeset/plugin-auth-find-envelope-limbs.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -A self-registration grant is refused, not silently redirected, when a permission-set row is malformed — and the fourteen dead `{ records }` / `{ data }` normalizer limbs behind that code are gone. - -`plugin-auth` carried fourteen array-or-envelope normalizer blocks of the shape `Array.isArray(x) ? x : x.records ?? []` (thirteen on a `records` limb, one on a `data` limb, four of them written as a guard clause rather than a ternary). All fourteen read the same concrete engine — the `ObjectQL` instance the kernel registers as the `objectql` / `data` service — which answers a bare array on every path, populated or empty. The envelope limb was unreachable code that read as a contract, so the next author writing a defensive normalizer here believed an envelope was possible. The limbs are removed, and the three local engine ports that declared `Promise` (`BootProbeEngine`, `DevAdminSeedProbeEngine`, `PhoneSmsTemplateEngine`) now declare the array they always returned. - -The user-visible change is in `settleSelfRegistrationGrant`, which carried the opposite defect. Its candidate filter dropped any permission-set row whose `id` was missing or blank, silently, before choosing which row to grant: - -- When the malformed row was the only one, the operator was told `no active sys_permission_set row named 'X' resolves` — false, since an active row named exactly that was present. That report is the only signal this path emits, and nothing retries it. -- When the malformed row was the **organization-scoped** one and a global row also carried the declared name, dropping it let the `organization_id == null` arm match instead, and the self-registrant was granted the **global** permission set their organization never declared — with a success log and no other trace. - -`active !== false` remains a selection predicate: a deactivated set still reports the ordinary "does not resolve". A malformed row is no longer a selection at all — the grant is refused and the report names the malformed row, so the ambiguity is surfaced instead of resolved by accident. A well-formed family grants exactly as before. - -**Upgrade note — one family now gets a refusal where it previously got a grant.** If a deployment's `sys_permission_set` already contains a row that is active and carries the declared name but whose `id` is missing or blank, self-registration grants against that name now stop and report, including the case where the malformed row is one nobody was relying on: a malformed **global** row sitting alongside a well-formed **organization-scoped** row used to be dropped silently, letting the org row be granted, and is now refused. This is deliberate — the old behaviour could not tell that family apart from the one where the silent drop granted the *wrong* set — and it is fully reversible without a code change: repair or delete the malformed row and the grant proceeds exactly as before. The refusal is loud and names the row, so it is visible rather than something to discover later; nothing is written while it stands. diff --git a/.changeset/plugin-describe-ui-type-spelling.md b/.changeset/plugin-describe-ui-type-spelling.md deleted file mode 100644 index cca7dfd3ab..0000000000 --- a/.changeset/plugin-describe-ui-type-spelling.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The `PluginSchema` describe strings for `staticPath`, `slug` and `default` now name `ui`, the plugin type the enum actually accepts. - -`PluginSchema.type` is `z.enum(['standard', ...CORE_PLUGIN_TYPES])`, and `CORE_PLUGIN_TYPES` spells the frontend member `ui`. The three describe strings beside it still named `ui-plugin` — a value the same schema refuses two lines above. They are not merely stale: they read as instructions ("Required for `type="ui-plugin"`"), so an author or an agent following the field's own documentation writes a value that is then rejected, with the correct spelling nowhere in the sentence that sent them there. - -The strings now read `(Required for type="ui")`, `(Required for type="ui")` and `(Only one "ui" plugin can be default)`. Because these describes compile into the published JSON Schema and into the generated reference page, the correction reaches every consumer that reads field documentation out of the spec rather than out of the source file — the generated `content/docs/references/kernel/plugin.mdx` table now agrees with the `type` row printed directly above it, which previously listed `'ui'` among the accepted members while the three rows underneath told the reader to write `ui-plugin`. - -No accept/reject behaviour moves: `type: 'ui-plugin'` is refused before and after, `type: 'ui'` is accepted before and after, and no key is added, renamed or removed. The closed-set pin tests that name `ui-plugin` as a non-member are deliberately unchanged — they are the reason this correction is provable. diff --git a/.changeset/plugin-dev-i18n-detect-packages-reader.md b/.changeset/plugin-dev-i18n-detect-packages-reader.md deleted file mode 100644 index 40375119b5..0000000000 --- a/.changeset/plugin-dev-i18n-detect-packages-reader.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/plugin-dev": patch ---- - -fix(plugin-dev): the i18n auto-detect resolves `translations` from `packages[]`, not only the flattened top level (#15232) - -`DevPlugin.init`'s 3b block read `options.stack.translations` and nothing else. -For a multi-package app under the ADR-0130 D4 option-B shape — where -`packages[]` carries each definition exactly once and the flattened top-level -copy is gone — that read returns `undefined`, the detection concludes "this app -declared no copy", and the boot continues. Nothing throws and nothing logs. - -What the developer gets instead is the wrong strings. `I18nServicePlugin` -(`@objectstack/service-i18n`) is never registered, so the `i18n` slot keeps the -core in-memory fallback: `os dev` serves message KEYS, or last release's copy, -for an app that declared real translations. It reads as "the translations are -broken", not as "a collection went missing", which is why it is a reader fix -rather than a footnote. - -The detection now reads the flattened top level FIRST and then each package -body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`, -ADR-0130 D4+D5) registers them: - -- **Every artifact the platform emits today answers bit-identically.** The - flattened level still answers first and short-circuits, so the `packages[]` - pass can only supply a declaration the top level did not have. This is the - reader half of the ruled order (readers first, emitter last, the artifact - additive throughout), so it lands with no change to what any command emits. -- **The caller's original expression is preserved, not re-expressed.** - `Array.isArray(t) && t.length > 0` still decides the top level, per package - body as well — re-expressing a gate as a resolved-and-counted traversal is - what silently changes the verdict for a stack that declares the key empty. -- **⛔ `stack.packages` is not iterated directly.** - `resolveArtifactPackageOrder` is the platform's one traversal and also the - GATE that parses each entry, so a second traversal would disagree with the - load path about which artifacts are loadable. An artifact with no `packages` - key is left entirely on the old path — the key's absence is checked before - the call, because D4's second branch would otherwise hand the caller's own - object back and read the same `translations` twice. -- **A malformed `packages` is refused, not skipped.** A non-array `packages`, - an entry inlined instead of wrapped under `manifest:`, or a duplicate package - id raises the same ADR-0112 envelope (`code` + `status: 422`) that - `ObjectQL.registerApp` raises for the same object later in the same boot. - -The decision — detection plus the locales it derives — is now one exported -function, `devI18nPluginOptions`, so the #15004 option-B acceptance pin -measures it by CALLING it rather than re-implementing the read. `DevPlugin` -keeps the dynamic import and its degradation: those are about the optional -package being installed, which is a different question from what the stack -declares. diff --git a/.changeset/plugin-security-default-set-answer-not-container.md b/.changeset/plugin-security-default-set-answer-not-container.md deleted file mode 100644 index a128ef51d6..0000000000 --- a/.changeset/plugin-security-default-set-answer-not-container.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) - -`declaredPermissionSets` carried a docblock stating a short-circuit its code did -not have: - -> The `packages[]` pass only supplies a set where the top level had none — which -> is precisely the option-B artifact. - -The code pushed the flattened top level and then **every** package body -unconditionally, so on today's additive artifact (flattened level *and* -`packages[]` both present) every permission set was collected twice. Nothing -observable came of it — the sole caller is private and takes the first -`isDefault` set, which the flattened copy still supplied — so this corrects a -false written contract on a security-path reader, not a live defect. That -distinction is the point: the sentence was load-bearing, because it was the -stated reason the reader half was revertible on its own and safe to land before -the emitter half (#14512), and the next reader would have believed the mechanism -was there. - -⚠️ Release-notes note: this supersedes one sentence of the #15226 entry in this same -unreleased batch — "The resolution now reads the flattened top level FIRST and then each -package body". That described #15226 accurately when it landed; after this change the -`packages[]` pass runs only where the top level named no default. The earlier entry is -left as written rather than retro-edited, so whoever compiles the notes collapses the two -deliberately instead of reading a contradiction. - -The reader now walks the discipline the docblock claims — start from the -expression this program replaced, `appDefaultPermissionSetName(config.permissions)`, -and consult `packages[]` only where it came back `undefined`. - -- **The condition is the resolved NAME, never the `permissions` container.** - Branching on the container re-creates the silent loss the reader program - exists to remove, one shape further along: a flattened level that carries - permission sets but marks none of them `isDefault` is legal today and - hand-authorable in any `objectstack.config.ts`, and a container-shaped - condition (`Array.isArray(flattened)`, with or without `&& length > 0`) shorts - it past the whole `packages[]` pass and answers `undefined` — nothing thrown, - nothing logged, every member of the app back down to the platform floor alone. - Reading the answer also retires the `[]`-is-truthy trap rather than patching - around it. -- **The package order is resolved BEFORE the top level is consulted.** - `resolveArtifactPackageOrder` refuses a malformed `packages` — not an array, - an entry inlined instead of wrapped under `manifest:`, a duplicate package id - — with an ADR-0112 envelope this reader does not catch, and that refusal must - not become conditional on whether the flattened level happened to name a - default first. An artifact is either loadable or refused; which level answered - is not part of that question. -- **No emitted artifact changes its answer.** Measured, not argued: 26 shapes — - the composed additive artifact, its option-B derivative, the collection-zoo - fixtures behind the #15004 acceptance pin, every config the unit suite drives, - the three malformed-`packages` refusals, and the hand-authored mixed shapes — - return byte-identical results before and after, with `@objectstack/plugin-security` - rebuilt and the change proven present in `dist/` on each leg. diff --git a/.changeset/plugin-security-scanner-ledger-entry.md b/.changeset/plugin-security-scanner-ledger-entry.md deleted file mode 100644 index c904cd9283..0000000000 --- a/.changeset/plugin-security-scanner-ledger-entry.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919) - -`PluginSecurityScanner`, `ScanTarget` and `SecurityIssue` are removed from -`@objectstack/core` in the same PR, under ADR-0049 enforce-or-remove (maintainer -ruling 2026-09-05, director summon #14, decision batch #42). This is the ledger -half: a D3 semantic entry -(`src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts`, -concatenated into `MIGRATIONS_BY_MAJOR[18].semantic` by `gen:migration-registry`) -so the retirement reaches `spec-changes.json` and the generated upgrade guide -rather than being invisible to every upgrade channel. - -FROM `new PluginSecurityScanner(kernel.logger)` → TO nothing: delete the import -and every call. There is no replacement export, and a caller that branched on -`result.status === 'passed'` takes that branch unconditionally — it is the only -branch the scanner ever produced, because four of its five scan methods returned -an empty issue list on every input and the fifth read a vulnerability database -whose only writer had zero callers. - -Why an entry is owed at all, and why D3 rather than a D2 conversion: the class -has no spec schema and never had one. It is a runtime TS class, so there is no -authorable key to tombstone with `retiredKey()` and no stored `sys_metadata` row -a conversion could rewrite — a scanner was constructed per call and every result -lived in a per-instance Map discarded with the object, so -`applyConversionsToStoredItem` has no seam that would ever see one. The enforced -channel is tsc at the consumer's own import site; for anyone it does not reach, -this entry and the upgrade guide are the only channel. That is the -`contracts.IDataDriver.findStream` and `actor-user-roles-to-positions` -disposition, applied to a surface one layer further out than either — those are -declared in `packages/spec`, this one only in `packages/core`. - -Measured, and worth recording because the entries README warns of a regeneration -lap that did not materialise here: `check:generated` reports all 15 artifacts up -to date after the entry landed, and running `gen:spec-changes` and -`gen:upgrade-guide` explicitly moved neither file — a major-18 semantic entry is -not yet projected into either. `registry.ts` is the whole generated diff. - -No behaviour in `@objectstack/spec` changes; this adds a ledger row and the -regenerated region that carries it. diff --git a/.changeset/plugin-security-scanner-retired.md b/.changeset/plugin-security-scanner-retired.md deleted file mode 100644 index b05dacd33c..0000000000 --- a/.changeset/plugin-security-scanner-retired.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) - - - -**ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in -`MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion, -and so not the metadata migration the ruling excludes. The class has no spec schema -and never had one, so there is no authorable key to tombstone with `retiredKey()` -and no stored `sys_metadata` row a conversion could rewrite: a scanner was -constructed per call and every result lived in a per-instance Map discarded with the -object, so `applyConversionsToStoredItem` has no seam that would ever see one. An -entry is nevertheless owed rather than optional, because this changeset carries a -real consumer prescription — the enforced channel is tsc at the import site, and for -any consumer it does not reach, the ledger and the generated upgrade guide are the -only channel there is. Same disposition as `contracts.IDataDriver.findStream` and -`actor-user-roles-to-positions`. - -**BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, -together with its two companion types `ScanTarget` and `SecurityIssue`. Landing -as `minor` under the repo's launch-window convention for breaking changes. -**There is no replacement**, and none is planned. - -⚠️ **The out-of-repo consumer population for these three exports is NOT -MEASURED.** This changeset can state only what was measured *inside* the -sources this repo can read: zero constructors in objectstack, zero in objectui -at the pinned sha, and zero in the deleted example itself. How many published -consumers of `@objectstack/core` import the class is unknown — no download, -dependent or source telemetry was consulted. Read the removal as breaking for -an unmeasured population, not as a removal proven to break nobody. - -## Why it was removed rather than repaired - -The class was a shell that reported success. `scan()` composed five private -scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, -`scanConfiguration`) allocated an empty issue array, logged, and returned it -with no code in between — none could report a finding for any input. The fifth, -`scanDependencies`, ran a real loop but matched only against an in-memory -vulnerability database whose sole writer, the public `addVulnerability`, had -zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing. -The database was therefore empty on every code path that has ever executed, so -no issue was ever produced, the score stayed 100, and the result was -`status: 'passed'` for every plugin the scanner was ever handed — a malicious -one included. - -A security control that cannot fail is worse than no security control, because -callers rely on it. Repair — writing a real vulnerability scanner — was refused -by name: it is a feature with a design surface and no demand, not a defect fix. - -## FROM → TO - -```ts -// FROM — compiles today, and passes every plugin it is given -import { PluginSecurityScanner } from '@objectstack/core'; - -const scanner = new PluginSecurityScanner(kernel.logger); -const result = await scanner.scan({ pluginId, version, dependencies }); -if (result.status === 'passed') { await kernel.use(plugin); } - -// TO — delete it. The condition above was always true. -await kernel.use(plugin); -``` - -**The one-line fix:** delete the import and every call; no symbol replaces it. -If your code branched on `result.status`, take the `'passed'` branch — that is -the only branch it ever took. - -**If you were relying on it for actual security**, you were not getting any. -Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`, -Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited -third-party plugin as untrusted code. What ObjectStack does still enforce is -artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the -plugin signature verifier — "is this what the publisher signed?", never "is -this safe?"), explicit plugin **permissions**, and the sandbox **resource -limits**; all three are unchanged. - -Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of -2026-09-05 (director summon #14, decision batch #42). The retirement is pinned -as an export-list assertion on both barrels in -`packages/core/src/security/security-scanner-retirement.pin.test.ts`. diff --git a/.changeset/plugin-sharing-field-recipient.md b/.changeset/plugin-sharing-field-recipient.md deleted file mode 100644 index 89177fd28b..0000000000 --- a/.changeset/plugin-sharing-field-recipient.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/plugin-sharing": minor ---- - -feat(plugin-sharing): the `field` sharing recipient is enforced — expanded once per matched record - -`ShareRecipientType` gained `field` on the spec side (#14103, maintainer ruling -B): `sharedWith: { type: 'field', value: '' }` shares each -record the rule's criteria match with the user or users named by that column -on the record. This is the executor half (#15072): - -- `SharingRuleService` reads the named user-typed column on each matched - record. A `multiple: true` column shares with every user it names; a single- - user column with the one it names. **Fail-closed on empty**: a null or empty - column materialises no grant — never a match-all principal, never a fallback - to the record owner. `field` is the only recipient resolved per record; every - other kind (`user`, `team`, `position`, `business_unit`, - `unit_and_subordinates`) still expands once per rule. -- The grants re-materialise on the record's own write: the existing - `afterUpdate` hook has no changed-field gating, so an update that touches only - the recipient column re-runs the per-record reconcile, which revokes the - stale grant and materialises the new one. No second trigger was added. -- The whole-rule pass (`evaluateRule` — the background re-grant after an - unbounded bulk write, the `kernel:bootstrapped` backfill and the REST evaluate - endpoint) derives per-record (record, user) pairs for a `field` rule instead - of a matched-records × recipients product, so the rule is as correct after a - bulk write and a restart as it is inline. The recipient-axis revoke - (`revokeRuleGrantsForRetiredRecipients`) declines `field` rules — they have no - rule-wide recipient set to retire against. -- The declared-rule bootstrap seeds `field` rules (previously skipped with a - warning), the `sys_sharing_rule.recipient_type` select accepts `field`, and - `defineRule` refuses a `field` recipient whose `recipientId` is not a field - name (the same grammar the spec applies at parse). -- An active `field` rule whose column the object does not declare as user-typed - grants nobody and says so once per rule. - -There is no `manager` recipient: "the owner's manager" is a user field the -application stores on the record, named by a `field` recipient. diff --git a/.changeset/preview-column-enrichment.md b/.changeset/preview-column-enrichment.md deleted file mode 100644 index 04389e6b07..0000000000 --- a/.changeset/preview-column-enrichment.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Analytics: a draft-preview dataset response now describes its columns like the live one - -`AnalyticsService.queryDataset`'s ADR-0037 P3 draft-preview branch returned before the -ADR-0021 result-column enrichment ever ran, so a dataset queried while the base object had a -pending seed draft came back with none of its column metadata: `fields[].label`, `format`, -`currency`, `percentScale`, `builtinAggregate`, and the temporal `type` correction were all -absent, on measure and dimension columns alike. A renderer then fell back to humanizing the -raw measure name and guessing a percent scale from magnitude — so the same dataset in the -same widget described its columns differently depending only on whether a pending seed draft -existed, which is the surface an author is looking at while authoring the dataset. - -Every one of those keys is read off the authored dataset and the source object's field -metadata, never off the rows, so the enrichment is now one method both paths call. Dimension -VALUE label resolution (resolving a lookup id to a display name) stays skipped on the preview -path deliberately: drafted seed rows reference lookups by name, so there is no id to resolve. diff --git a/.changeset/provenance-stamp-per-row-dispatch.md b/.changeset/provenance-stamp-per-row-dispatch.md deleted file mode 100644 index 80fb62496d..0000000000 --- a/.changeset/provenance-stamp-per-row-dispatch.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-sharing": patch -"@objectstack/plugin-webhooks": patch ---- - -The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. - -`sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: - -- **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. -- **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. - -Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. diff --git a/.changeset/public-sharing-enabled-canonical-predicate.md b/.changeset/public-sharing-enabled-canonical-predicate.md deleted file mode 100644 index 17b3856580..0000000000 --- a/.changeset/public-sharing-enabled-canonical-predicate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-sharing": patch -"@objectstack/runtime": patch ---- - -`publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. - -`isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. - -Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. - -Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. diff --git a/.changeset/public-sharing-enabled-standing-policy-tsdoc.md b/.changeset/public-sharing-enabled-standing-policy-tsdoc.md deleted file mode 100644 index 1aaf837f68..0000000000 --- a/.changeset/public-sharing-enabled-standing-policy-tsdoc.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Document `publicSharing.enabled` as the standing policy it is, and name the switched-off block among `resolveToken`'s `null` causes. - -The TSDoc above `publicSharing.enabled` read "when false, no share links can be issued for this object" — true, but only the mint half. Since the switch became a standing policy held at every redemption, a block that is off also stops every existing link on it from resolving: links minted while it was on, and links minted through the system-context / `permissive` mint bypass alike. Re-enabling the block serves them again; no row moves. The comment now says so, in the shape the sibling `eligibility` predicate's prose already uses. - -`IShareLinkService.resolveToken` enumerated the causes of its undifferentiated `null` — unknown, revoked, expired, audience, password, record gone, ineligible — without the switched-off block, so an implementer reading the list to enumerate refusal causes got an incomplete set. The list now carries it, in the position the gates run; the contract's design notes gain a matching entry beside the eligibility one, and the `isSystem` mint bypass is marked as mint-only. - -Documentation only: no schema, shape or behaviour change, and the `.describe()` string that feeds the generated reference is untouched. Where the corrected text reaches consumers, measured on the built package: every new line in `share-link-service.ts` ships in the published `dist/contracts/index.d.ts` (the interface-member docs and the module design notes both survive the declaration bundle); the `object.zod.ts` property comment reaches no `.d.ts` (the schema's declaration is an inferred type) and ships through the source file `@objectstack/spec` publishes directly (`src/**/*.zod.ts`) and through `dist/data/index.js.map`. diff --git a/.changeset/published-cli-stderr-nonblocking-guard.md b/.changeset/published-cli-stderr-nonblocking-guard.md deleted file mode 100644 index 60ce0d2ac3..0000000000 --- a/.changeset/published-cli-stderr-nonblocking-guard.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -The published `os` binary no longer freezes in the kernel when whatever is reading its output stops draining. - -Node puts the CLI's stderr on the non-blocking write path when it opens the pipe, so a write to a reader that has stopped is buffered rather than parking the thread. libuv clears that flag again in the pre-exec of every child spawned with **inherited** stdio — and inheriting is `dup2`, so the flag lives on an open file description the spawner shares. Clearing it for the child clears it for the CLI too. - -Measured on the built binary, `os dev --verbose` with its output piped to a reader that stopped draining: `os dev` spawns `os serve --dev` with inherited stdio at 2.8 s, that child spawns the esbuild service with inherited stderr at 5.2 s, and fd 2 stays blocking for the rest of the run. 3.1 s after the reader stopped, the main thread sat in `write(2)` (`wchan=sock_alloc_send_pskb`), 4 of 4 runs — parked 28.9 s, **ignoring SIGINT while parked**, and released only when the consumer resumed. Not a crash and not a timeout: alive, idle, unresponsive, with an empty log. Anything that pipes `os dev` and reads it slowly — a CI log collector, a backgrounded runner, a supervisor that stops draining while it does work — could park the CLI this way. - -`bin/run.js` now installs `keepStderrNonBlocking()` before oclif can write a byte. The guard re-asserts `O_NONBLOCK` immediately ahead of each write, which is what the measurement requires: the clearing that persisted was made by a **grandchild** the CLI does not spawn and cannot see, so a one-shot at startup would be undone silently and no change to the CLI's own spawn sites would have prevented it. - -The guard itself is not new — it shipped in no published install. It lived at `packages/cli/bin/stderr-nonblocking.mjs`, and `files` names only `dist`, `README.md` and `CHANGELOG.md`; npm packs a `bin` **target** regardless of `files`, which is why `bin/run.js` reached every install and the module beside it reached none. It now compiles from `src/utils/stderr-nonblocking.ts` into `dist/`, under the whitelist that was already there. - -Nothing about which arguments the CLI accepts, what it prints, or what it exits with changes. The refusal of `setBlocking(true)` in `src/utils/format.ts` stands and is untouched — this is its inverse, and what keeps its premise true. diff --git a/.changeset/quiet-pans-repair.md b/.changeset/quiet-pans-repair.md deleted file mode 100644 index b88b4b5d68..0000000000 --- a/.changeset/quiet-pans-repair.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Remove seven dead `{ records }` union-normalizer limbs on engine `find()` results, and repair the one that was silently dropping instead of gapping. - -Six seams in this plugin normalized an engine read as `Array.isArray(x) ? x : x.records`. The envelope limb was unreachable: `ObjectQL.find` resolves a bare array of row objects, measured by booting a real engine over a real `SqlDriver` and driving each seam through the shipped function that owns it, rather than inferred from `IDataEngine.find`'s declared `Promise` (a declared type is not proof — this repo also has a `find()` that resolves an envelope). Each seam keeps its existing disposition for a non-array; only the dead limb is gone. - -The seventh is repaired in the opposite direction. `SecurityPlugin`'s `sys_permission_set` loader mapped three different facts onto one value: a read that succeeded on an empty catalog, a read that threw, and a read that resolved something it could not read all left as `[]`. On the enforcement plane that silently withdraws grants that exist while every request still looks normal, and it made `PermissionEvaluator`'s existing "db lookup failed" warning unreachable — so a transient database error and an empty catalog produced identical, undiagnosable 403s. The loader now lets the read fault propagate and refuses an unreadable result with `DATABASE_ERROR`. Enforcement is unchanged for every result the shipped engine produces; an envelope or a non-row element now refuses (fail-closed) where the old code read through it. An unanswered read still grants nothing; what changes is that it is now reported instead of silent. diff --git a/.changeset/record-picker-filter-rule-array.md b/.changeset/record-picker-filter-rule-array.md deleted file mode 100644 index 67f9ef2f67..0000000000 --- a/.changeset/record-picker-filter-rule-array.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B) - - - -**BREAKING** accept-set change on one props-map entry, shipped as `minor` under -the repo's launch-window convention for breaking changes; the migration -prescription is registered under protocol major 18. - -One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, -verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2), -`element:record_picker`'s `filter` was the one `filter` input in -`ComponentPropsMap` still declared as the MongoDB-style record -(`FilterConditionSchema`) while the three array-declared siblings -(`record:related_list`, its nested Add-affordance picker, `element:number`) -declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare -`filter` as `z.unknown()`, #15449 — so the filter a list view stores and -renders was refused by the picker beside it. The entry now declares the same -array form those siblings do, and the `FilterConditionSchema` import that existed for this -one site leaves the file with it. - -Sequenced measurement-first, as that convergence had to be: the `record_picker` -read path was measured at the objectui pin before the declaration moved. The -renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose -`convertQueryParams` lowers a rule array through `translateFilterArray` into -filter AST tuples — the door every list view's stored rule array already takes -— and nothing on that path parses `properties` against the installed spec. - -**Migration** (`element-record-picker-filter-rule-array` — listed by -`os migrate meta --from 17` once the protocol major is 18): a record-form `filter: { status: 'active' }` becomes -`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an operator -object `{ amount: { $gt: 100 } }` becomes -`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys -become several rules (they AND). The record form is refused at `filter` -(`invalid_type`, expected array). The binding-level `dataSource.filter` on the -same node is a different key and is unchanged by this release. - -`ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed -state now differs from its authored state on `filter` (`operator` normalizes on -parse), so the bare alias is no longer isomorphic. diff --git a/.changeset/reference-page-block-tag-payload-render.md b/.changeset/reference-page-block-tag-payload-render.md deleted file mode 100644 index 9ad26d1bdd..0000000000 --- a/.changeset/reference-page-block-tag-payload-render.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Reference pages no longer print `@example` and `@category` tag lines as literal text. - -A module docblock is JSDoc, so its header carries block tags, and the reference-docs -renderer emitted a tag written on a prose line verbatim — 18 such lines reached 14 -customer-facing pages, as `@example Basic field mapping` above a code fence and -`@category Security` at the foot of four `system/` pages. `#13796` removed `@module` -from the page and left these two open, because a blanket `^@\w+` line filter would -have taken reader prose off the page and orphaned the fences below it. - -The verdict is per tag, and the axis is the payload rather than the spelling: - -- **`@example CAPTION` is REWRITTEN** into that caption, in bold, above the block it - captions — the shape `@see` already had (`See also: …`). 12 lines across 10 pages. - Bold rather than a heading because heading renumbering has already run by then, so - an emitted heading would carry a level chosen blind of the page, add entries to the - pages' tables of contents, and put a caption in reach of `check:docs-single-h1`. -- **A bare `@example` is DROPPED.** With no payload it is the `@module` case exactly, - and the fence beneath it is visibly an example without a line announcing one. 2 - lines (`studio/plugin`, `studio/object-designer`), both sitting against the - `check:skill-examples` opt-in marker that was already dropped there. -- **`@category VALUE` is DROPPED.** 4 lines, all reading `Security`, on four pages that - already sit under a `system/` section saying as much — and nothing in the repo reads - the tag: no typedoc or api-extractor (neither is used here), no search index, no - gate. Routing it into page frontmatter instead would publish a field with no - consumer. The tag stays in the source, where it is a legitimate JSDoc tag; only the - rendered page drops it. - -No schema behavior changes. The pins assert on the rendered fragment rather than on the -emitted `.mdx`, because `check:docs` compares the artifact against the source and -reproduced all 18 tag lines faithfully. diff --git a/.changeset/references-door-organization-forwarding.md b/.changeset/references-door-organization-forwarding.md deleted file mode 100644 index a642db13da..0000000000 --- a/.changeset/references-door-organization-forwarding.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The admin "Used by" panel no longer clears a delete when the caller's own organization is using the item. - -`GET /api/v1/meta/:type/:name/references` backs that panel, whose empty case reads "Nothing in the metadata graph points at this item. Safe to delete." — advice given to an operator about to delete something. The door supplied no organization, so the reference sweep read the environment partition only: an organization-scoped `view` (or `dashboard`, `report`, `translation`, `email_template`) pointing straight at the object being deleted was invisible, and the panel issued a false clearance. It now passes the caller's organization, and those references are returned. - -The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field or contract surface. `req.params.type` is the reference TARGET, while the sweep spends the organization on the SOURCES it reads per type; `getMetaItems` applies the `allowOrgOverride` read gate to its own request type, so each source is scoped on its own registry flag. A non-overridable source (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into a delete clearance. An anonymous or organization-less caller reads exactly what it read before, and no status code or response shape moves. diff --git a/.changeset/references-door-refusal-envelope-converged.md b/.changeset/references-door-refusal-envelope-converged.md deleted file mode 100644 index 9c777959ec..0000000000 --- a/.changeset/references-door-refusal-envelope-converged.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it. - -The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message: - -``` -A the protocol cannot answer for this TARGET type (a `field`) - 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} -B the resolved kernel has no `findReferencesToMeta` at all - 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} -``` - -A now answers in B's shape, carrying the producer's own sentence: - -``` -501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} -``` - -Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `.` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator. - -Two consequences for a caller: - -- `body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered. -- Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched. diff --git a/.changeset/registry-conflict-code-constants.md b/.changeset/registry-conflict-code-constants.md deleted file mode 100644 index 8b53f1bbd5..0000000000 --- a/.changeset/registry-conflict-code-constants.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -The registry's three conflict refusals now publish their error `code` as an importable constant. - -`SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. - -Three new exports from `@objectstack/objectql`: - -- `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal. -- `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal. -- `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal. - -**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary. - -**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. - -**The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace. diff --git a/.changeset/remote-loader-list-nameless-guard.md b/.changeset/remote-loader-list-nameless-guard.md deleted file mode 100644 index a78137b5e7..0000000000 --- a/.changeset/remote-loader-list-nameless-guard.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`RemoteLoader.list()` no longer reports a nameless remote body as a literal `undefined`. - -The method declares `Promise` and read the collection as `loadMany<{ name: string }>(type)` before mapping `items.map(i => i.name)`. That type argument is an **assertion** about bodies that arrived over HTTP, and nothing checked it: a body with no top-level `name` yielded `undefined`, which went into an array the signature declares as `string[]`. `MetadataManager.listNames()` unions loader `list()` output unfiltered, so the violation reached consumers — measured on this fixture, `listNames()` answered `[ 'account', undefined, 42 ]`. - -The guard is `DatabaseLoader.list()`'s, one file away: the same cast-then-map spelling with `.filter(name => typeof name === 'string')` behind it. `RemoteLoader` was the only one of the four loaders in that directory with no guard at all — `MemoryLoader` answers with its store keys, and `FilesystemLoader` reports only names `findFile()` resolves. Dropping silently rather than throwing is the direction those siblings already carry: a name in the list that the door answers `null` for is the silent failure an author reads as their own typo, so the list is narrowed to agree with the door. - -Nothing that was validly returned before stops being returned: the only entries that disappear are the ones whose type the signature already ruled out. A caller that previously received `[undefined]` now receives `[]`. `loadMany()` is deliberately untouched — it keys nothing, so a body carrying no `name` is still served there; this loader reads over HTTP and holds no store key, so `body.name` is the only identity it has and the family's "identity is the store key" rule cannot be satisfied for it. diff --git a/.changeset/report-chart-axis-own-selection.md b/.changeset/report-chart-axis-own-selection.md deleted file mode 100644 index 1ddd27ef6e..0000000000 --- a/.changeset/report-chart-axis-own-selection.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`chart-axis-not-selected` resolves a report chart against its own `chart.yAxis`, not `report.values` (#15734) - -**Behaviour change — one false finding removed on the report surface.** A report chart whose `chart.yAxis` names a declared measure that `report.values` does not select no longer raises a `chart-axis-not-selected` warning. Nothing else about the rule moves, and no other surface moves at all. - -The warning stated a query consequence the renderer refutes. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), `plugin-report/src/DatasetReportRenderer.tsx` does not query `report.values` for the chart at all — it runs the chart's own, narrower query out of the two axis strings: - -``` -const state = useDatasetRows( - dataset, - plan.kind === 'series' && xAxis ? [xAxis] : [], - wantsQuery && yAxis ? [yAxis] : [], -``` - -and says so in that file's own words at the `scopeOrder` docblock: *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. So the measure the warning said "the query does not return" is exactly the one the query asks for, and the chart plots it. `report.values` is the selection of the TABLE beneath the chart. - -Both limbs follow from that one measurement: - -- **No not-selected check at the report `chart.yAxis`.** That position IS the chart's query, so it cannot fail to select itself. `chart-measure-unknown` there is untouched: an UNDECLARED measure is still no column at all, and still an `error`. -- **`chart.series[].name` resolves against the singleton `{ chart.yAxis }`.** The entry is a display-name override paired with a DERIVED series, and the chart derives exactly one (`buildChartSeries(…, [xAxis], [yAxis], …)`). An entry naming `chart.yAxis` now lands however the table is selected, and one naming any other declared measure is still reported — including a measure `report.values` does select, which it could not reach before. - -The list-view and page-component surfaces are unchanged, and carry firing controls that say so: on both, `values` IS the measure set the query asks for (`ObjectView` hands it to the chart; `ObjectChart` queries `{ dimensions: schema.dimensions, measures: schema.values }`), so the existing resolution is the right one there. - -The per-position tier and consequence wording is untouched — only the SET the report surface resolves against moves. diff --git a/.changeset/rest-api-config-consumes-parse.md b/.changeset/rest-api-config-consumes-parse.md deleted file mode 100644 index 6c8b40cf45..0000000000 --- a/.changeset/rest-api-config-consumes-parse.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The REST server's `api` configuration defaults now come from `RestApiConfigSchema` alone, instead of being restated in `packages/rest`. - -`RestServer.normalizeConfig` already parsed `config.api` against `RestApiConfigSchema` — and then discarded the result, rebuilding the block from a `??` chain over the raw input. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in a second package. They agreed key for key, and nothing measured that they would keep agreeing: changing a default in `@objectstack/spec` silently failed to propagate, because `api.enableUi ?? true` answers `true` for an absent key whatever the schema declares. Consuming the parse deletes the duplicate and makes the schema authoritative. - -The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default. - -One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat` — and it runs in two directions, not one. **Filled in:** those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination`. **Stripped:** inner keys the schema does not declare no longer survive, at either depth — an authored `documentation.logo`, a `documentation.contact.phone` or a `documentation.license.spdxId` inside the nested objects, a `responseFormat.extra` — where the `??` chain passed the authored object through by reference and kept every key on it. Both halves are the same parse: `documentation` / `responseFormat` (and their `contact` / `license`) are non-strict `z.object()`s, which fill in their `.default()`s and drop what they do not name — dropped silently, so this is a strip and not a new refusal. An object left unwritten stays absent, and nothing in the platform reads either key today: the normalized block is `private` to `RestServer`, which reads only scalars off it (`apiPath` / `basePath` / `version` in `getApiBasePath`, the `enable*` flags, `projectResolution`), and the repo has no other read site for either key — so no consumer observes either half. diff --git a/.changeset/rest-data-doors-compiled-against-protocol.md b/.changeset/rest-data-doors-compiled-against-protocol.md deleted file mode 100644 index 880a0230f8..0000000000 --- a/.changeset/rest-data-doors-compiled-against-protocol.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The REST data doors' protocol requests are compiled against the declared contract again, so a field added to a data request schema reddens the build instead of going silently unsent. - -No runtime behaviour changes — every door assembles and forwards exactly the object it did before. What changes is what the compiler is allowed to see. `packages/rest/src/rest-server.ts` dispatched to the protocol through two erasing forms: `p.deleteData({ … } as any)` on the argument, and the stronger `(p as any).updateData({ … })` on the protocol object itself, which erases the check on *every* member — a misspelled method name would not have errored. Across the file that was 22 dispatch sites spanning `findData` / `getData` / `createData` / `updateData` / `deleteData`, their `*Many` and batch siblings, and `getUiView`. - -The casts were load-bearing rather than lazy: these call sites pass `environmentId` and `context`, and neither is a member of any data request schema. Neither should become one. `environmentId` is the transport routing key that selects the kernel *before* the protocol call and is already ruled out of the request shape; `context` is the server-derived execution context, and a caller-supplied `context` is a privilege escalation the ingress deletes unconditionally — putting it in the published request schema would re-open that door. Both are now declared on a typed envelope alongside the request type, so they stay server-side *and* compiled, and every other member of every literal is checked against the spec. - -One slot stays deliberately untyped and is now named rather than diffuse: `findData`'s `query` accepts both the declared AST and an undeclared wire dialect (`$top`, `$orderby`, `filters`, …) that the protocol normalizer folds. Three server-built literals speak that dialect; the erasure there is confined to the query slot alone, and the declared-versus-shipped mismatch is filed as its own question. diff --git a/.changeset/rest-generic-passthrough-object-key.md b/.changeset/rest-generic-passthrough-object-key.md deleted file mode 100644 index b24c8e33b9..0000000000 --- a/.changeset/rest-generic-passthrough-object-key.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest): the generic declared-status passthrough names its object on both error doors (#14725) - -**Response-body change on the published bulk / metadata / UI doors: one optional -key is added, `object`.** Nothing is removed, no status moves, and no `code` -value changes spelling. - -#14541 made the two REST error doors agree for every refusal a *bespoke* arm -classifies. They still disagreed for every refusal that reached the *generic* -declared-status passthrough, because the two copies of that one passthrough -differed by exactly one key: `classifyDataError`'s copy ends -`...(object ? { object } : {})` and `resolveErrorResponse`'s 4xx arm had no such -limb. Measured on `main` @ `a12b15e394` — one error object, both doors: - -| door | before | -|---|---| -| `mapDataError(err, 'duly_note')` (single-record `/data`) | `409 {"error":"…","code":"DUPLICATE_RECORD","object":"duly_note"}` | -| `sendThrownError(res, err, 'duly_note')` (bulk / metadata / UI) | `409 {"error":"…","code":"DUPLICATE_RECORD"}` | - -One refusal, two bodies, decided by which route caught it — the #14541 shape one -arm over. The bulk door now answers the first row too. - -It closes the same card's second residue with it. `recordNotFoundError` -(`@objectstack/core`) declares `code`, `status = 404` **and** `object`, so that -declared status carries a record-level not-found past the `RECORD_NOT_FOUND` arm -into this same generic passthrough on every route reporting through -`handleRouteError` / `sendThrownError`, while the single-record `/data` door -reached the generic arm in `classifyDataError` and shipped the name. Both doors -now agree for that producer in every combination of declared status and -door-supplied object. - -**Who sees the new key.** The name comes from the door's `object` *argument*, -never from `error.object`, so only a route that supplies one is widened. Of 35 -route call sites of this door, **9** pass an argument that can be a non-empty -object name — `POST /data/:object/batch`, `/createMany`, `/updateMany`, -`/deleteMany`, `POST /data/:object/:id/clone`, `POST /data/:object/import`, -`POST /data/:object/import/jobs`, `GET /data/:object/export`, and -`GET /ui/view/:object/:type`. The other 26 (21 passing nothing, 5 passing the -literal `''`) answer byte-identical bodies. `classifiedRefusalAnswer` — the -entry point the analytics dataset face and the record-share family re-dress — -calls this door with no `object` argument at all, so those envelopes' key sets -do not move. - -**What deliberately does not change.** The declared-**5xx** arm gains nothing: -its sibling `declaredServerFaultAnswer` names no object either, so the two doors -already agreed in that band and adding the limb there would *create* a -divergence, on top of putting a caller-supplied name into a body whose whole -rule is that a declared server fault says nothing beyond status and code. The -`RECORD_NOT_FOUND` arm's message-**text** limb -(`/^Record \S+ not found in \S+/i`) is not lifted above the passthrough either — -that boundary is #14541's, and it is now pinned behaviourally and positionally -rather than described. - -Consumer note: a client that key-counts or exact-matches an error body from a -bulk, import, export, clone or UI-view route will see `object` alongside `error` -and `code` where the equivalent single-record `/data` response has carried it all -along. A client that reads named fields is unaffected. diff --git a/.changeset/rest-translate-options-default-locale.md b/.changeset/rest-translate-options-default-locale.md deleted file mode 100644 index ab37741797..0000000000 --- a/.changeset/rest-translate-options-default-locale.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711) - -`translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf. - -Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5. diff --git a/.changeset/runtime-declarative-row-update-executor.md b/.changeset/runtime-declarative-row-update-executor.md deleted file mode 100644 index d4aca34c61..0000000000 --- a/.changeset/runtime-declarative-row-update-executor.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -feat(runtime): the platform action route executes the declarative row-level `operation: 'update'` action (#14092) - -The spec half (#15077) made `operation: 'update'` + `patch` parse; nothing performed the -write, so an authored update action reached the action route with no handler and collected -the registry's loud not-registered answer. It now performs the write. - -`POST /api/v1/actions///` — and the MCP `run_action` bridge, through -the same shared executor — performs exactly ONE data-plane update of the current record: - -- **As the caller.** The write carries the caller's own `ExecutionContext`, never the - `isSystem`-elevated context a `type: 'script'` BODY runs under. There is no author body here - to trust, so the data plane's own gate is the only gate — the object's permissions, its hooks - and its validations fire exactly as for a user edit, and their refusals reach the caller with - their own `code` and `status`. This consumes the `runAs: 'user'` direction ruled on #14010; no - `runAs` key is added. -- **A caller who cannot read the row is refused before anything is written** (404 - `RECORD_NOT_FOUND`, the platform's one existence-non-disclosing envelope), by consuming the - caller-scope load's verdict rather than re-deriving it from the stamped `record.id` — the - #14143 class: a swallowed load must never become an implicit grant. -- **The write is `{ ...patch, ...collectedParams }`** — static values under the dialog's, so a - param of the same name wins. Nothing else from the action is merged, and the ADR-0104 D2 param - contract still bounds what the wire can add. -- **No current record ⇒ a located refusal**, never a silent no-op: no `recordId` on the route or - in the body, an action addressed at the object-less key, or an empty write bag each answer 400 - naming the action and the fix. -- **`undoable: true`** returns `undo: { type, objectName, recordId, undoData, redoData }` — the - prior values of exactly the fields written, `null` for a field the row did not carry, so the - existing Undo readers can restore. The three remaining `UndoableOperation` keys (`id`, - `timestamp`, `description`) stay the client's. -- `visible` is deliberately unread here: it is a per-record renderer predicate, and the - authorization is the point above. - -`operation` is read BEFORE `type` at every reader, so the HTTP door and the MCP bridge agree: -`isHeadlessInvokableAction` now accepts a declarative update (it has neither `target` nor `body` -by construction), `headlessActionTypeError` hands it no client-side-type prescription, and -`summarizeAction` reports `operation` and `requiresRecord: true`. - -Unchanged: a handler-less `type: 'script'` action WITHOUT `operation` still gets today's -not-registered 404 — the script path is not widened. diff --git a/.changeset/runtime-gate-stored-metadata-universe.md b/.changeset/runtime-gate-stored-metadata-universe.md deleted file mode 100644 index fe25983f27..0000000000 --- a/.changeset/runtime-gate-stored-metadata-universe.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -A dashboard bound to a dataset you just saved now publishes, without restarting the runtime. - -The author-time gate that runs on every `active` metadata publish resolves a widget's `dataset` (and a `type: 'page'` view's `pageName`, and the sibling collections the cross-collection security rules compare against) against a resolution universe the host gathers per write. That gather read the SchemaRegistry alone. The registry is filled at boot by code packages, and for every metadata type except `object` a runtime write does not reach it — so a dataset saved through `PUT /api/v1/meta/dataset` was invisible to the gate until the process restarted, while `GET /api/v1/meta/dataset` returned it in the same instant with `_diagnostics.valid: true`. - -Measured on the reported shape, in one process with no restart between the steps: the row is in `sys_metadata`, the read API lists six datasets, the registry lists the five code-package ones, and a three-widget board bound to the new dataset was refused `422` with three `widget-dataset-unknown` issues whose hint enumerated every dataset except the one just authored. The same request answered `200` after a restart, nothing else changed. - -The gather now folds the stored half onto the registry half for every collection it carries. What that does and does not do: - -- **Additive.** A stored row contributes a name the registry does not already carry and never displaces a registry entry — an object's registry copy is its resolved schema (base plus `extend` contributors) and a raw `sys_metadata` row is the base layer alone, so replacing it would trade this phantom for a subtler one. Where an org overlay redefines a code-package item, the gate still judges that item's content from the registry's version. -- **Active rows only.** A draft does not resolve. The refuse-at-publish ruling exists so an author can write the widget first and the dataset second; a draft dataset that satisfied a published board would invert it. -- **Scoped to the write's own partition** — environment-wide rows plus, when the write has one, its own organization. No other organization's overlays are visible to the gate, on any kernel. -- **A failed store read is reported, not swallowed.** Context gathering still never fails a write, but a read that fails for any reason other than an unprovisioned `sys_metadata` now says so once, naming the consequence — a gather that silently shrinks is how a phantom refusal is manufactured in the first place. - -The rules themselves are unchanged: a reference that resolves in neither home is still refused, with the same code, status and key path. diff --git a/.changeset/runtime-job-timeout-ms.md b/.changeset/runtime-job-timeout-ms.md deleted file mode 100644 index d63d3be90d..0000000000 --- a/.changeset/runtime-job-timeout-ms.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): `AppPlugin` threads the authored `job.timeoutMs` to the scheduler as `timeoutMs` (#14478) - -The declarative job door passes `{ retryPolicy, timeoutMs }` to -`IJobService.schedule`, following the `@objectstack/spec` rename of the -authored key and of the `JobScheduleOptions` contract key that carries it. Same -value, same per-attempt limit. diff --git a/.changeset/runtime-state-file-project-key.md b/.changeset/runtime-state-file-project-key.md deleted file mode 100644 index 7bb9751ae3..0000000000 --- a/.changeset/runtime-state-file-project-key.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record. - - - -**BREAKING** for anything that opens the runtime state file by its old name. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file `os serve` writes under the ObjectStack home was named `runtime..json` and is now named `runtime...json`. - -`os serve` publishes `{ pid, port, url, environmentId, startedAt }` to a file under the ObjectStack home, so a supervisor can answer *"is my server running, and where?"*. That file was named `runtime..json`, and both halves of where it lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `local` environment, therefore wrote one file. - -Driven with two real boots, two project roots and one home, that produced two failures with one cause: - -- project B's boot replaced project A's record, so a reader asking about A's server was answered `pid`/`port`/`url` belonging to **B** — confidently, while A's own server was still alive and still listening elsewhere; -- project A's shutdown then deleted the file that by that point described **B**, leaving a running server with no supervision record at all. - -The file is now `runtime...json`, where the project component is a sanitised basename plus a short digest of the served app's root — the same root `serve` already resolves for host-anchored package loads. The payload is unchanged: no new key, and in particular no database path (which #15374 ruled out deliberately, because it would turn a best-effort supervision file into an identity contract). - -**If you read this file:** a reader that hard-codes `runtime..json` now gets `ENOENT` rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob `runtime.*.json` inside a home they pinned themselves (as `scripts/publish-smoke.sh` does) are unaffected. A `runtime..json` left over from an earlier version is no longer written or cleaned up by `os serve`; delete it once. - -**Which root the project component is taken from**, for a supervisor that has to reconstruct the name out of tree: it is the app root `serve` anchors at, which is the config file's own directory when that file exists and that directory carries a `package.json`, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent. - -Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here. diff --git a/.changeset/runtime-tenancy-posture-failure-discrimination.md b/.changeset/runtime-tenancy-posture-failure-discrimination.md deleted file mode 100644 index 546f00ce42..0000000000 --- a/.changeset/runtime-tenancy-posture-failure-discrimination.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The runtime dispatcher door no longer admits a request on a tenancy posture it could not read. - -`resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants. - -The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text: - -- **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders. -- **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent. - -Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request. diff --git a/.changeset/sandbox-writeback-entry-snapshot-normalised.md b/.changeset/sandbox-writeback-entry-snapshot-normalised.md deleted file mode 100644 index 247a4a7410..0000000000 --- a/.changeset/sandbox-writeback-entry-snapshot-normalised.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): a sandboxed hook body no longer launders an untouched `readonly` field onto the row - -A `beforeUpdate`/`beforeInsert` body running in the sandbox made the engine believe it had -written payload keys it never named, and a `readonly` field the caller supplied then survived -the readonly strip and landed. Measured end to end: with `locked_at` declared -`{ type: 'datetime', readonly: true }` and seeded to `2020-01-01`, a caller sending -`locked_at: new Date('2099-12-31…')` alongside a body whose whole source is -`ctx.input.touched_by = 'hook'` stored the caller's 2099 value — while the same object's -readonly `text` field was correctly stripped in the same request. - -The cause was a comparison of unlike things. The write-back decides whether a body wrote -*through* an object-valued key by comparing the host payload value against the VM's exit dump, -and the dump has been through `JSON.stringify`/`JSON.parse` while the host value has not. A -`Date` therefore never compared equal to its own ISO projection, took the documented -"cannot prove equal ⇒ carry it back" path, and was re-asserted onto the proxy that records -which keys a hook wrote. The class was every object-valued value a JSON round-trip cannot -prove equal — an object carrying an `undefined` member included, a `Date` being only its most -reachable member. - -The entry value is now normalised through the same round-trip the VM saw before it is -compared. The same change ends a fidelity loss on non-readonly fields: an untouched key is no -longer carried at all, so a host `Date` is no longer replaced by an ISO string on its way to -the driver. - -Fail-open behaviour is unchanged for values the round-trip genuinely cannot evaluate: a cyclic -or bigint-bearing payload value is still reported as changed and still carried, per key. diff --git a/.changeset/scaffold-emission-policy-one-definition.md b/.changeset/scaffold-emission-policy-one-definition.md deleted file mode 100644 index c62f323ba8..0000000000 --- a/.changeset/scaffold-emission-policy-one-definition.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`objectstack init` and `objectstack create` now read one emission policy instead of each restating it. - -Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript. - -The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`. - -Two emitted values had to survive the merge, and both are argued rather than picked: - -- **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3. -- **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two. - -**Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each. - -`npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs. diff --git a/.changeset/schema-drift-single-value-json-column.md b/.changeset/schema-drift-single-value-json-column.md deleted file mode 100644 index 4095cd124f..0000000000 --- a/.changeset/schema-drift-single-value-json-column.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -Schema drift now reports a SINGLE-VALUE JSON-class column that a stale `varchar`/`text` column is holding — the population the detector could never see. - -The driver decides a field's column type with `JSON_COLUMN_TYPES.has(type) || !!field.multiple`: `createColumn` gives a json column to every JSON-class TYPE, and `isJsonField` — the read-side deserializer — asks the same question. The drift detector asked only `field.multiple === true`. So a single-value `file` / `image` / `location` / `address` / `record` / `vector` / `json` field (and the option families) sitting on a `varchar` or `text` column was written as JSON by the writer and did not exist to the differ. Because the additive sync never migrates a column's type, that column stayed wrong permanently and nothing reported it. Measured on the previous tree, one call per type: all fifteen JSON-class types the spec declares returned zero findings over a `character varying(2048)` column on `postgres` and `mysql`, while the same column under a `multiple: true` field returned one in the same run. - -The detector now reads the writer's own predicate, so the two halves can no longer disagree about which declarations get a json column. `SQLite is unchanged and still reports nothing`: its read path parses a textual column regardless of what the column calls itself, re-measured on an in-memory cell as a byte-identical round-trip between the stale column and the driver's own. - -**The remedy is offered to the array-valued half only.** `os migrate multi-value-columns` repairs a stale column by wrapping each stored value in a one-element JSON array, which is the right repair for a field whose value is a list and the wrong one for a field whose value is a scalar or an object. Findings for array-valued fields (`multiple: true`, and the inherently-multi option types) keep their message character for character, so that command keeps recovering the dialect from it and keeps working exactly as before. Findings for single-value JSON-class fields carry a message of their own that names neither the command nor its statement, explains why the automated route is withheld, and describes the by-hand conversion; the command refuses such an entry (`remedy_not_recognized`) instead of running array SQL over scalar rows. - -Also fixed by the same predicate: a single-value JSON-class field declaring a `maxLength` over a wider `varchar` column used to be reported as `narrow_varchar` at category `destructive` — inviting `os migrate apply --allow-destructive` to rewrite the column to a narrower varchar, the opposite of the repair it needs. It is now reported once, as the base-type divergence. diff --git a/.changeset/scope-less-booted-row-attribution.md b/.changeset/scope-less-booted-row-attribution.md deleted file mode 100644 index 5d02576295..0000000000 --- a/.changeset/scope-less-booted-row-attribution.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/runtime": patch -"@objectstack/metadata-protocol": patch ---- - -docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) - -Comment and prose only. No predicate, no assertion and no served shape changes; -every pin behind the `writable` verdict stays green as written. - -The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own -explanation, and this corrects it at every site that repeated it. The claim was -that the scope-less booted row `isWritablePackage` answers `false` for is *the -`type: module` sub-package a multi-package artifact carries*. It is not, and it -never was: - -- `defineStack` parses every `packages[]` entry through `ManifestSchema` - (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is - `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of - a compiled artifact is ever scope-less — `dist/objectstack.json` and both - served rows carry `scope: "project"`. -- A genuinely scope-less row arises only where a manifest reaches the registry - **without** that parse, because `installPackage` stores a key-by-key copy that - applies no defaults: a marketplace install / offline file import - (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, - read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to - `installPackage`) for the **database base, writable** half. - -Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an -unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` -of the same unparsed body yields a record with no `scope` key at all. - -What stays, because it is true and load-bearing: a scope-less **booted** package -is read-only while a scope-less **database base** is writable, and only -`engine.manifests` tells them apart — which is why the server owns the verdict. diff --git a/.changeset/score-metadata-lint-crash-visible.md b/.changeset/score-metadata-lint-crash-visible.md deleted file mode 100644 index 1b2de23d85..0000000000 --- a/.changeset/score-metadata-lint-crash-visible.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`scoreMetadata` no longer scores a stack whose linter crashed as a perfect one. - -The metadata rubric is two halves: a schema parse and the lint sweep. When `lintConfig` threw, the scorer caught the throw and continued with `issues = []` — so the penalty was 0 and a stack half of whose rubric never ran came back as **100 / grade `A` / `valid: true`, every count zero, `issues: []`** — byte-for-byte the verdict a genuinely clean stack gets. "The linter found nothing" and "the linter never ran" collapsed into the better-looking one. - -The crash is reachable on a schema-valid stack: a localized `label` (`{ en: 'Todos', 'zh-CN': '待办' }`) on an app, or on a view's `list`, parses clean and makes the label-case rule throw a `TypeError`. That rule's crash is a separate defect, filed on its own; what changes here is that the scorer stops publishing a clean verdict it did not earn. - -A crashed lint run is now recorded in every carrier a consumer might read, because reading any one of them has to be enough: - -- **`lintError`** — a new optional string on `MetadataScore`, carrying the thrown message. Set only when the linter could not run; absent when it ran and reported errors, which is a lint verdict rather than a missing one. It reaches the CLI's published payload through `os lint --eval --json`, on `results[].score`. -- **A synthetic `error` issue** (`rule: 'rubric/lint-crashed'`, exported as `LINT_CRASHED_RULE`) — so `issues`, `counts.errors` and `valid` carry the failure too. This is what makes the eval harness fail the case: its `passed` reads `counts.errors`, and would never have seen a new field. It still fails at `--eval-min 0`, where the score alone stops discriminating. -- **`score: 0` / grade `F`** — the only channel `os lint --score --json` publishes, and the same refusal `unscorableScore()` already gives an eval case there was nothing to judge. - -The schema half is untouched and still reported: `schemaErrors` and `counts.schemaErrors` say exactly what the parse found, which was the defensible half of the original intent. diff --git a/.changeset/screen-flow-headless-satisfaction.md b/.changeset/screen-flow-headless-satisfaction.md deleted file mode 100644 index eb138b9d93..0000000000 --- a/.changeset/screen-flow-headless-satisfaction.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/runtime": minor ---- - -A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. - -An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated. - -Two independent halves: - -- **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`). -- **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing. - -**Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. - -**Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `Id`, or when its value equals what the bag carries under `recordId`, `Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too. - -⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause. - -⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change. diff --git a/.changeset/seed-apply-read-back-decorations.md b/.changeset/seed-apply-read-back-decorations.md deleted file mode 100644 index 07f7fa8b47..0000000000 --- a/.changeset/seed-apply-read-back-decorations.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The package-publish door's route-level seed apply can consume the platform's own read-back envelope again. - -`POST /packages/:id/publish-drafts` reads each just-published `seed` body back through `protocol.getMetaItem` before handing it to the seed loader. That read exits through `decorateMetadataItem`, which stamps `_diagnostics` on every body whose metadata type has a registered schema — `seed` has one — and `SeedSchema` has been closed since protocol 17. So the door refused the document it had just been served: `unrecognized_keys: ["_diagnostics"]`, minted as a 422 and delivered on a **200** as `seedApplied.error`. Zero rows loaded, and the author was told their seed body failed spec validation when nothing about it was wrong. - -The read-back is now passed through `stripReadDecorations` at the unwrap — the same helper, for the same reason, that the dataset query, the cold-boot flow bind and `saveMetaItem`'s verbatim persist already call. `METADATA_READ_DECORATIONS` is the declared list of keys the read path derives from a document and attaches to the *response*, so removing them restores the document the author actually wrote. - -Nothing is widened to accept them: `SeedLoaderRequestSchema` stays closed, and the publish response keeps its declared shape. The strip is deliberately **not** a blanket `startsWith('_')` sweep — the ADR-0010 protection envelope (`_packageId`, `_provenance`, …) is not a read decoration, and the metadata schemas allowlist it precisely so a served document keeps its provenance when it is parsed again. - -Only protocols that do not self-apply seeds inside `publishPackageDrafts` reach this path; the shipping protocol self-applies and was never affected. diff --git a/.changeset/seed-read-drops-dead-org-rung.md b/.changeset/seed-read-drops-dead-org-rung.md deleted file mode 100644 index 7c249b06fe..0000000000 --- a/.changeset/seed-read-drops-dead-org-rung.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row. - -`applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`". - -That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type). - -The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries. - -One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves. diff --git a/.changeset/serve-unlinked-database-file-watch.md b/.changeset/serve-unlinked-database-file-watch.md deleted file mode 100644 index 7a85c645f2..0000000000 --- a/.changeset/serve-unlinked-database-file-watch.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path. - -Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle. - -A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now). - -It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine. diff --git a/.changeset/service-analytics-text-operator-non-text-column.md b/.changeset/service-analytics-text-operator-non-text-column.md deleted file mode 100644 index 90bd3fe9b1..0000000000 --- a/.changeset/service-analytics-text-operator-non-text-column.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer. - -`compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant. diff --git a/.changeset/service-datasource-turso-timeout-ms-reader.md b/.changeset/service-datasource-turso-timeout-ms-reader.md deleted file mode 100644 index cd2e32c621..0000000000 --- a/.changeset/service-datasource-turso-timeout-ms-reader.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) - -`buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — -still consulted `config.timeout` after #15680 renamed that authored key to -`timeoutMs` and tombstoned the old spelling. A turso datasource authored the -canonical way therefore reached the seam, matched nothing, and had its timeout -**silently dropped**: no diagnostic in any channel. - -The reader now consults `config.timeoutMs`. The DRIVER key it lands on is -unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is -published-but-inert (#16024), and renaming an inert key would ratify it as real, -which is what ADR-0049 exists to prevent. So this seam is the one place the -authored and driver spellings differ, and it now says so. - -## No fallback arm for the retired spelling — the seam's own precedent - -Both sibling arms in `default-datasource-driver-factory.ts` already answer this -in the same words: sqlite's "`filename` is the whole contract … so no `??` -tolerance survives here", mongo's "`url` is the one spelling". A renamed -datasource config key reaches a reader already canonical from two directions — -authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` -`never` plus a parse-time prescription), and a stored `sys_metadata` row replays -the full ADR-0087 chain including `retiredFromLoadPath` entries at -`loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion -`turso-config-timeout-to-timeout-ms` has rewritten the key before this table -sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a -spelling both doors have closed. - -`authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route -(host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs -itself, which never meets the authoring schema), not for a retired spelling. - -## Why the covering test did not catch it, and what replaces it - -`TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a -rename through it — the tombstone's type channel, which caught the alias tables -elsewhere in this stack, does not reach here. And the covering test authored the -**retired** spelling at all three of its turso `config` sites, so it was green -for exactly the behaviour that had become wrong. A test that pins the retired -spelling cannot notice this class of bug. - -The three sites now author the canonical spelling, and the file gains cases -DERIVED from the authoring contract rather than written against today's key -list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert -that (a) every canonical replacement is consulted by some reader, and (b) no -retired spelling is — probed at every JS type a reader could type-test, with a -vacuity guard so a mis-derived empty list fails instead of passing. They hold -for the next rename without being edited. - -The two sibling pins that author the same spec — `packages/cli`'s driver -correspondence check and `packages/runtime`'s cross-loader convergence check — -move to the canonical spelling with it; their assertions read driver keys and -are unchanged. diff --git a/.changeset/service-job-timeout-ms.md b/.changeset/service-job-timeout-ms.md deleted file mode 100644 index d8a48f708f..0000000000 --- a/.changeset/service-job-timeout-ms.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-job": patch ---- - -fix(service-job): `runWithPolicy` and the DB job adapter read `JobScheduleOptions.timeoutMs` (#14478) - -The per-attempt time limit is read from `options.timeoutMs`, following the -`@objectstack/spec` rename of both the authored `job.timeoutMs` and the -`JobScheduleOptions` contract key that carries it. Same value, same per-attempt -race, same `JobTimeoutError`; `withoutPolicy` strips the renamed key so the -timer adapter downstream never runs a second budget. diff --git a/.changeset/service-storage-test-tsc-program.md b/.changeset/service-storage-test-tsc-program.md deleted file mode 100644 index 0879cbebd1..0000000000 --- a/.changeset/service-storage-test-tsc-program.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -fix(service-storage): put the test layer in front of tsc, and repair what it was hiding (#15050) - -`packages/services/service-storage` had **no `typecheck` script at all** — its -scripts were `build` and `test` — so no tsc program anywhere read this -package's test layer, and its errors were carried instead as a 51-error DEBT -entry in `scripts/check-type-check-coverage.mjs`. Gives it the #14062 / -#14181 "checked test zone" shape: a sibling `tsconfig.test.json` (module -semantics only — `esnext` / `bundler` / `lib: ES2022` — matching how vitest -actually executes these files; strictness inherited and untouched) plus a -`tsconfig.scripts.json` for `scripts/i18n-extract.config.ts` (the ninth -instance of #11351, previously excluded from that ledger only because this -package had no `typecheck` script to hang it on), both named by a new -`typecheck` script. - -Measured before repair: 51 errors under BUILD semantics (`tsc --noEmit -p -tsconfig.json`, which already includes the tests — matching the DEBT entry's -recorded number exactly), 10 under the split. Unlike `service-cluster` -(#14181), this package's BUILD reading was *not* already clean, so both -programs needed genuine repair, not just the test-only split: 23 `TS2835` -(relative imports missing their `.js` extension, required under BUILD's -NodeNext resolution) were fixed by *adding* the extension — which resolves -correctly under both NodeNext and the split's bundler mode — and clearing -that also cleared all 15 `TS7006` "implicitly any" as a downstream cascade -from the same unresolved imports (the shape `@objectstack/core` reported at -98 → 4). The remaining 3 `TS2550` (`Array.prototype.at` needing `lib` -es2022) are rewritten to indexed access rather than widening the shared -BUILD `tsconfig.json`. The 8 code-tier errors (`TS2339` × 4 — a test -helper's object-spread dropped its `Record` index -signature, fixed with an explicit return-shape annotation; `TS2347` × 4 — a -fake `ctx: any`'s `getService(...)` calls converted to `getService(...) -as T`, the pattern one call site in the same file had already adopted for -exactly this reason) are genuine test-file fixes. Both readings now agree at -0/0 — the same result `service-cluster` reported, reached by a longer road. - -The package's DEBT entry (51 errors) is **deleted**, not lowered — the -graduation this ratchet's invariant requires. No `test-typecheck-debt.json` -is added: residue is 0, so none is owed (#5286, maintainer-only to open). -`check:type-source-resolution` went red from onboarding the two new -programs (the documented onboarding-limb case): a registry entry is added -rather than `paths`, measured both ways — `paths` takes this package's test -layer from 0 errors to 306, all in other packages' source. - -No runtime code changes: `src/**` excluding tests is byte-identical, so no -shipped behaviour moves. The `patch` level reflects the published -`package.json` gaining `typecheck` / `check:test-typecheck` scripts and a -`tsx` devDependency. diff --git a/.changeset/session-payload-positions-security-axis.md b/.changeset/session-payload-positions-security-axis.md deleted file mode 100644 index 97ca7023fb..0000000000 --- a/.changeset/session-payload-positions-security-axis.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -"@objectstack/plugin-auth": minor -"@objectstack/spec": minor ---- - -fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) - - - -**BREAKING** meaning change on a published payload — `user.positions` in -`GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's -launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on -#15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one -name, one meaning. - -`customSession` built the array from the better-auth `sys_user.role` scalar -split on commas, plus the active membership mapped to `org_*`, plus -`platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 -D4 table that is the source of truth for custom positions. The Console binds -that array straight through as the CEL root `current_user`, so an -`action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed -by a business position answered FALSE for **everyone**, including the user who -genuinely held it. - -⭐ It failed **silently and in the invisible direction**: the root was bound and -the key was present, so `has(current_user.positions)` was true, CEL raised -nothing, and the predicate simply returned FALSE. A predicate that *faults* -fails OPEN in the shell and would have shown the button; a successful FALSE -shows nothing and reports nothing. The documented example -(`'org_admin' in current_user.positions`) kept working throughout, because -`org_admin` is the one name that sits on **both** axes. - -This was a **declared** contract being violated, not an ambiguous name: -`EvalUserSchema` already specified `positions` as "built-in identity names + -position names", exposed to "every predicate surface (server formula, server -RLS, client UI gates) ... with an identical shape" so that a predicate -"evaluates identically wherever it is written". `/auth/me/permissions` and -every server-side evaluator (`ExecutionContext.positions`) already resolved the -security axis; only the session payload did not. - -**What changes** - -- `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, - not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE - authority (`core/security/resolve-authz-context.ts`, whose header forbids - every entry point from re-reading the `sys_*` grant tables itself), scoped to - the session's active organization. The payload therefore carries the - `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and - agrees with `/auth/me/permissions` set for set. Same move - `isPlatformAdminUserId` made at #10348. -- `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as - an alias of `'platform_admin' in positions`), so one authority answers both. -- `packages/spec` — `EvalUserSchema` states which axis `positions` is, and - states that the better-auth role scalar is not it. - -**No key is renamed, and none is added.** The ruling anticipated a renamed -auth-role array; measured against the tree, it has no content to carry and no -consumer. Everything the old union contributed beyond the security axis was the -`sys_user.role` scalar's own tokens — and that scalar is **already published, -unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban -carves out, for third-party schema this platform does not own). Minting a -`roles` array would revive that banned word to publish information the payload -already carries. (Precisely: `check:role-word` ratchets the reserved word in -`content/docs` and `skills/` PROSE, while the identifier ban over authored -metadata lives in `packages/lint`; a TypeScript payload key trips neither -mechanically until it is documented. The ADR-level prohibition is what rules -here, not a gate that would have caught it.) A consumer that wants the -better-auth role reads `user.role`. - -**What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); -`platform_admin` still derives from the unscoped `admin_full_access` grant with -its ADR-0091 validity window and ADR-0049 active flag intact — -`platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over -those shapes. - -⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** -`positions.includes('platform_admin')` is the form -`resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` -row may spell that very name — and this card is what made that reachable, by -moving `positions` onto an axis a tenant admin can write. Reading the name would -have let a tenant mint platform standing and pass the `/admin/*` mount gate. -`platform-admin-gate.ts` drops its positions leg for the same reason. -`session-platform-admin-rung-agreement.test.ts` requires the payload alias, that -gate and `hasPlatformAdminStanding` to agree, driven with such a row present and -a genuine grant as the control. - -**Upgrade.** If you gate on the better-auth role scalar, read `user.role` -instead of looking for its tokens in `user.positions`. Predicates written -against real position names, built-in identity names, or `everyone` need no -change — they start working. Deployments that stored business role names in -`sys_user.role` rather than assigning positions should assign them through -`sys_user_position` (the governed ADR-0090 D12 channel). - -A name in `sys_member.role` is still projected, **with one carve-out**: for a -session carrying NO active organization, membership names are now *added*, from -**every** membership the user holds — the resolver projects them all when no -tenant scopes it, where the old derivation contributed none. Measured on the -real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): -`[]` before, `[org_admin, everyone]` after, pinned by -`session-positions-security-axis.test.ts`. With an active organization the -projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so -membership-derived names there are unchanged. diff --git a/.changeset/session-unbacked-org-claim-dropped.md b/.changeset/session-unbacked-org-claim-dropped.md deleted file mode 100644 index 43502e0f68..0000000000 --- a/.changeset/session-unbacked-org-claim-dropped.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/core': patch ---- - -A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data. - -Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path. - -That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`. diff --git a/.changeset/session-user-language-retired.md b/.changeset/session-user-language-retired.md deleted file mode 100644 index 36d9459e7d..0000000000 --- a/.changeset/session-user-language-retired.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049) - - - -**BREAKING** key removal on a published session type, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the -prescription is registered under protocol major 18 — `api/SessionUser:language` -in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry -`session-user-language-retired` — where `os migrate meta` users will look). - -`SessionUserSchema.language` (`api/auth.zod.ts`) was declared -`z.string().default('en')` and described as "Preferred language", and had no -producer and no consumer anywhere: no session endpoint ever wrote it, no client -ever read it (objectui measured at its pinned sha: zero readers; the only -in-repo mentions were the schema's own unit test). A reader trusting the -published contract got a constant that was not the user's language — while the -user's real preference had just landed as the first-class column -`sys_user.locale` (#13881), which the session type could not see. Three -spellings of one concept on the published surface, none of them right. The -maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 -enforce-or-remove and make `GET /auth/me/localization` the ONE read face for -the signed-in user's language. No replacement field joins the session contract -until a session endpoint really produces one — no dual-spelling window. - -FROM → TO: - -- `SessionUser.language` / `SessionUserParsed.language` → *(removed)*. Read - the signed-in user's language from `GET /auth/me/localization` → `locale`, - which now resolves the user's own `sys_user.locale` when set → the request's - `Accept-Language` → the deployment default (`@objectstack/plugin-hono-server` - in the same release). - -One-line fix: delete the key. A producer still writing it fails `tsc` -(`never` input type) and fails to parse with this prescription; a reader still -keying on it now reads `undefined` instead of a permanent `'en'`, and should -read `locale` off `/auth/me/localization` instead. - -The retirement kit: - -- **`retiredKey()` tombstone** (the schema is a non-strict `z.object`, so a bare - delete would have stripped the key silently — ADR-0104): writing `language` - is a `tsc` error and a parse error carrying the prescription, on - `SessionUserSchema` and through both envelopes that embed it - (`SessionResponse.data.user`, `UserProfileResponse.data`). -- **ADR-0087 registration**: `api/SessionUser:language` under major 18 plus - the D3 semantic entry `session-user-language-retired`. A RESPONSE surface — - the server mints a `SessionUser`, nobody authors or persists one — so there - is no source for a D2 conversion to rewrite (the - `api/AuthFeaturesConfig:passkeys` disposition). -- **generated baselines**: `authorable-surface/api.json` carries the - `[RETIRED]` row; `authorable-defaults/api.json` drops the `= "en"` default; - `spec-changes.json`, the upgrade guide and `content/docs/references/api/auth.mdx` - regenerated. -- **pins** in `api/auth.test.ts`: the prescription on parse, absence (no default - minted) on a clean parse, both envelopes refusing the key, and a - `packages/spec/src`-scoped scan for any reader of `.language` off a - `SessionUser`. -- zero in-tree producers or readers, so no in-repo source changes ride along - beyond the endpoint change shipped with it. diff --git a/.changeset/settings-admission-tenancy-posture.md b/.changeset/settings-admission-tenancy-posture.md deleted file mode 100644 index 4b33e3e28d..0000000000 --- a/.changeset/settings-admission-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Fix: the settings REST doors now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply here — and the tenant this seam hands onward is a vetted one. - -Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran at this door, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. This gate does not merely admit the principal: it returns that tenant onward as the resolved settings tenant, so an unvetted claim became the verdict the read/write path acted on. A browser session whose stored active organization is no longer backed by a membership now has that claim dropped here too, rather than passed through. - -The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage rather than a quiet admission. diff --git a/.changeset/settings-door-value-domain-shared-predicate.md b/.changeset/settings-door-value-domain-shared-predicate.md deleted file mode 100644 index 9ce175ffdb..0000000000 --- a/.changeset/settings-door-value-domain-shared-predicate.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/service-settings": minor ---- - -fix(service-settings): the settings door answers from the ONE shared value-domain predicate, and refuses a non-member with `value_domain` (#15162) - - - -**BREAKING** for a client that branches on the refusal code. Landing inside -the launch window, so it ships as `minor` (the lockstep convention forbids -`major`); the banner is the carrier, not the bump. - -The services half of the maintainer's ruling of 2026-09-02: **one closed -vocabulary and one membership predicate shared by settings specifiers and -object fields**. The spec half declared them in `@objectstack/spec/shared`; -this package had been carrying a second copy of all three definitions since -`Specifier.valueDomain` shipped. The copies are deleted and the door now asks -`isValueDomainMember` — the call the record write path will make when the -engine half of the same ruling lands (PR #15316, still open). - -**The wire change**, measured on `PUT /api/settings/localization` with -`{"timezone": "Mars/Olympus"}`, base `a56baa2bd` vs this branch: - -| | before | after | -|:--|:--|:--| -| `fields[0].code` | `invalid_value` | `value_domain` | -| `fields[0].message` | `Default timezone must be a valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received 'Mars/Olympus'.` | `Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")` | - -Everything else is byte-identical: HTTP 400, the envelope code -`SETTINGS_VALIDATION`, `field`, `label`, `constraint: { valueDomain: … }` and -the echoed `value`. A client that reads `constraint.valueDomain` — the -machine-readable half ADR-0114 asks it to read — is unaffected. A client that -branches on `code === 'invalid_value'` for a domain breach must move to -`value_domain`. - -Why the code moved: ADR-0114's rule is that the code is the **constraint's own -name**, the way `max_length` names the bound it breached. This branch took -`invalid_value` — the catalog's slot for "rejected for a reason no other -member names" — only while no member named a standard-domain breach. The -field-level card's spec half added one, so the slot no longer applies. The -message now renders the published catalog template -`value_domain_` in `en` — the catalog the record write path will render -from once PR #15316 lands, so the two doors under one ruling will describe one -domain in one set of words instead of each composing its own sentence. For an `encrypted` specifier the offending value is still never -echoed: the template's value placeholder takes the same mask the REST boundary -uses (`fields[0].value` stays absent, as before). - -**No value changes verdict.** The accept sets were measured, not assumed, on -the repo's Node 22 baseline (v22.22.2): - -- `iso_3166_alpha2` — the two 249-code lists diffed mechanically before either - was deleted: identical, including order; symmetric difference 0. -- `iso_4217_currency` — this one changes DEFINITION: a run-time - `Intl.supportedValuesOf('currency')` probe becomes the key set of the - checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`. 162 codes vs 162, - symmetric difference 0 in both directions (`CHF` in both, `XYZ` in neither). - The behaviour that changes is that the verdict no longer varies with the - host's ICU build — the direction the shared module argues for. A door-level - test now re-measures it: every code the run-time probe admits must still be - admitted. -- `iana_time_zone` — the identical `Intl.DateTimeFormat` probe on both sides, - unmoved. - -A ratchet pin (`value-domains.shared-predicate.pin.test.ts`) reddens if any -non-test source in this package re-acquires a membership table, an `Intl` -enumeration probe, or a second caller of the predicate. diff --git a/.changeset/share-link-admission-tenancy-posture.md b/.changeset/share-link-admission-tenancy-posture.md deleted file mode 100644 index d66f7f7767..0000000000 --- a/.changeset/share-link-admission-tenancy-posture.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -The share-link REST surface now derives the tenancy posture before it resolves the caller, so an API key stamped with an organization its owner has left can no longer mint links into that organization. - -`resolveAuthzContext` gates every posture-conditional refusal on a `tenancyPosture` its **caller** supplies. `SharingServicePlugin`'s share-link door supplied none, so none of them ran: `organization_required` (`core/security/api-key.ts`), `organization_membership_ended` (`core/security/resolve-authz-context.ts`), and the session arm beside it that drops an `activeOrganizationId` claim no `sys_member` row backs. An API key's tenant is `sys_api_key.active_organization_id` copied verbatim — the caller's own stored claim, never vetted against current membership — so under a wall-enforcing posture (`isolated`, `group`) a key belonging to an ex-member was admitted carrying that organization, and `createLink` minted a capability token on a record inside it. The same door carried the session half: a browser session whose owner had been removed kept its organization claim until the session expired. - -Measured at the door, under `isolated`: the ex-member's key went from `200` / `201` with the link landing in the store to `401` / `401` with nothing landing; an organization-less key went from admitted to `401`; an ex-member's *session* now has its stale claim dropped and is refused by Layer 0 at `403` while staying signed in. A current member and an anonymous caller are unchanged in every wiring. - -A `tenancy` service that was **never registered** stays a supported composition and resolves quietly to "no posture" — behaviour on an embedding without `plugin-auth` is exactly what it was. A `tenancy` service that **was registered and failed to build** now raises `AuthzStoreUnavailableError`, which reaches the wire as `SERVICE_UNAVAILABLE` / 503 rather than being laundered into a `401`: admission was never decided, so it must not be answered. diff --git a/.changeset/sharing-rule-evaluation-result-grants-refused.md b/.changeset/sharing-rule-evaluation-result-grants-refused.md deleted file mode 100644 index a700304e5d..0000000000 --- a/.changeset/sharing-rule-evaluation-result-grants-refused.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `SharingRuleEvaluationResult` declares `grantsRefused?: number` — the optional seventh key the sharing-rule evaluate route already answers (#14969) - -`minor`, derived: a new key on a published contract interface is additive public -API (semver "backwards-compatible functionality"), and not `major` because the -key is **optional** — every existing `ISharingRuleService` implementer, in-tree -and out, keeps compiling unchanged, and every consumer typed against the six -counts keeps reading them. - -`POST /api/v1/sharing/rules/:idOrName/evaluate` (ledgered `sdk`, -`shares.rules.evaluate`) passes the service's return value through unfiltered, -and `@objectstack/plugin-sharing` has counted refused grants on its own subtype -since #14754 — so the wire carried `grantsRefused` while the declared client -type (`client.shares.rules.evaluate`, typed `Promise`) -could not name it without a cast. The client gains the key through its spec -import with no edit of its own. - -What the key means, and what its absence means: it counts the grants the -engine **refused** during the pass (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on -an organization-less insert into a tenant-scoped `sys_record_share`); the pass -continues past a refusal, so `grantsRefused > 0` is not a failed pass. The key -is **absent — not `0`** — from any implementation that does not count -refusals. A consumer branching on it must read "unset" as "this implementation -does not report refusals", never as "no grant was refused"; only a present `0` -says the latter. Do not `?? 0` it. - -Optional in the spec composes with the plugin-local narrowing: an -implementation that counts refusals may require the key on its own subtype -(`SharingRuleReconcilePassResult extends SharingRuleEvaluationResult`), a legal -covariant narrowing that still satisfies `ISharingRuleService`. diff --git a/.changeset/signup-existing-address-explicit-refusal.md b/.changeset/signup-existing-address-explicit-refusal.md deleted file mode 100644 index f642783331..0000000000 --- a/.changeset/signup-existing-address-explicit-refusal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/plugin-auth": minor -"@objectstack/spec": patch ---- - -`POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) - -**This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened. - -### What was measured - -Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at. - -### The mechanism - -better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear. - -The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture. - -### The fix - -The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically. - -**Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing. - -**Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt. - -`USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent. diff --git a/.changeset/single-kernel-tenancy-posture-provider.md b/.changeset/single-kernel-tenancy-posture-provider.md deleted file mode 100644 index 684f6b19c1..0000000000 --- a/.changeset/single-kernel-tenancy-posture-provider.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/rest": patch -"@objectstack/core": patch ---- - -fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 - -Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an -organization its owner is no longer a member of **read and wrote that -organization's rows** on the wiring the open core actually builds. Not a silent -empty set — a GET that returned the other organization's records, and a POST -that landed a row read back from the store carrying that organization's id and -the ex-member as its creator. An organization-less key on the same deployment -read `200` with an empty set, which is the silent failure the wall exists to -replace. - -The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the -effective tenancy posture from a per-request kernel, and on the single-kernel -wiring there is no per-request kernel — so the posture was `undefined` on every -request, and both posture-conditional API-key refusals are gated on it: -`organization_required` in `api-key.ts` and `organization_membership_ended` in -`resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was -active the whole time; it compares against the caller's active organization, -and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim -— the holder's own stored claim. Enforcing the wall is what let the ex-member -through, because the one fact that would expose the ended membership was not an -input to the layer that could act on it. - -The single-kernel branch now derives the posture from a provider `rest-api-plugin` -wires to the lone local kernel's `tenancy` service, in the same shape as the -auth-service provider beside it. A host that registers no `tenancy` service is -unchanged and still admits: there is no wall on such a deployment, so there is -nothing for an organization-less key to be walled out of. A `tenancy` service -that was registered and **failed to build** is an outage and answers `503`, not -an admission — a posture that could not be read is not a posture that is absent. - -Refusals are now also said out loud on the server side, at `warn`, where each -one is decided: the key's row id (never the credential or its hash), the -principal, the organization and the reason. **The wire is unchanged** — both -refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the -body, so a holder of someone else's key learns nothing a plain 401 does not -already tell them. The operator, who previously had a key that was neither -revoked nor expired and a 401 that said nothing, now has a line to find. - -Behaviour that does not move: a current member's key on the same route still -returns its rows and still writes; a request with no credential still answers -401; and an unknown, revoked or expired key is not a refusal at all, so a key -scanner produces no log volume. diff --git a/.changeset/spec-compose-key-dispositions-export.md b/.changeset/spec-compose-key-dispositions-export.md deleted file mode 100644 index dcf6c878b1..0000000000 --- a/.changeset/spec-compose-key-dispositions-export.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): export `COMPOSE_KEY_DISPOSITIONS` and `STACK_DEFINITION_KEYS` — the artifact envelope's top-level key set and each key's composition rule, derivable from one source instead of hand-copied per consumer (#14877) - -`minor`, additive: two new named exports and two new exported types on the -root entry; nothing renamed, narrowed or removed. Every existing import keeps -compiling and every behaviour of `composeStacks` is unchanged — the table it -reads is the same object, now frozen and public. - -- `COMPOSE_KEY_DISPOSITIONS` — a frozen, read-only record from every top-level - key `ObjectStackDefinitionSchema` declares (`manifest`, `packages`, - `requires`, `objects`, … `onEnable`) to its composition rule: `'concat'` - (an array collection, concatenated in stack order), `'single'` (identical - declarations pass through, differing ones refuse naming the key), - `'manifest'` (picked by the `manifest` option), `'objects'` (the - `objectConflict` strategy) or `'functions'` (merged by handler name). - Literal-typed, so `(typeof COMPOSE_KEY_DISPOSITIONS)[K]` is K's disposition, - not the union. -- `STACK_DEFINITION_KEYS` — the top-level key set, derived from that table by - `Object.keys` (never a second literal), frozen. -- `StackDefinitionKey` and `ComposeDisposition` — the key union and the - disposition union, for a consumer that types its own seam against them. - -Why: the collection half of this key set was already derivable downstream -(`PLURAL_TO_SINGULAR`, `METADATA_ALIASES`), but the non-collection keys — -`manifest`, `requires`, `packages`, and whatever comes next — had to be -hand-copied by every consumer that walks an artifact's top level, and that -copy drifted silently twice: objectstack-ai/cloud#897 (`roles` → `positions` -dropped every hosted `positions[]`) and objectstack-ai/cloud#1888 (`packages[]` -dropped by an artifact merge, recreating downstream the duplicate-ownership -state #14599 had repaired at the door). A seam that derives its key set from -`STACK_DEFINITION_KEYS` — and asks `COMPOSE_KEY_DISPOSITIONS[key] === 'concat'` -whether a key may be concatenated across artifacts — picks up the next key -(#14865's `grantedPermissions`) the day the schema declares it, with no edit of -its own. - -Pinned (`compose-key-dispositions-export.pin.test.ts`): the exported key set -equals `ObjectStackDefinitionSchema`'s declared top-level key set in both -directions, the view is frozen, every value is a declared disposition, every -`'concat'` key is what `composeStacks` concatenates and every `'single'` key is -what it passes through or refuses, and the key list is `Object.keys` of the -table. diff --git a/.changeset/spec-i18n-default-locale-authored-label.md b/.changeset/spec-i18n-default-locale-authored-label.md deleted file mode 100644 index c5239cbbd1..0000000000 --- a/.changeset/spec-i18n-default-locale-authored-label.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711) - - - -**BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`. - -## The ruling (#15711, recorded 2026-09-05) - -A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**. - -- `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked. -- `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label. -- A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required. -- `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset). - -Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default. - -Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence. diff --git a/.changeset/spec-notification-event-migration-id.md b/.changeset/spec-notification-event-migration-id.md deleted file mode 100644 index 786e4da2a7..0000000000 --- a/.changeset/spec-notification-event-migration-id.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`@objectstack/spec/system` now names the ADR-0030 notification cut-over, so "has this deployment run it?" has a place to be answered. - -`sys_migration` is the ledger a deployment writes to record that a data migration ran against its own database, and consumers read it instead of the platform version. Its well-known ids were `adr-0104-file-references` and `adr-0104-value-shapes` — the two ADR-0104 scans, both driven by an `os migrate` command that records the row. `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) had none. It is destructive and one-way, operators are handed the call verbatim in `docs/handoff/adr-0030-notification-convergence.md`, and it recorded nothing when it ran: a deployment that performed the cut-over and one that never did are indistinguishable from the ledger. A row can only be keyed by an id, so without one the question had nowhere to be answered even in principle. - -Added: `NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'`, exported from `@objectstack/spec/system`. Purely additive — no existing export, schema or predicate changes, and nothing reads the new id yet. - -Deliberately NOT decided here, and the constant's docblock says so rather than leaving its silence to be read as an answer: what a `sys_migration` row under this id means. The two ADR-0104 ids get their `last_run_at` / `applied_at` / `verified_at` / `blocking` semantics from a command that scans, self-checks and only then records; this migration has no command and no self-check, and reports `migrated` / `already_done` / `not_applicable` / `error` to its caller instead. Which of those columns one of its runs may claim, whether anything may gate on the row, and whether a datastore created after the cut-over belongs in `CREATION_ATTESTED_MIGRATION_IDS`, are contract questions on this surface and are left open. diff --git a/.changeset/stack-cross-reference-refusal-envelope.md b/.changeset/stack-cross-reference-refusal-envelope.md deleted file mode 100644 index 98d2f78249..0000000000 --- a/.changeset/stack-cross-reference-refusal-envelope.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/runtime": patch ---- - -fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) - -`validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. - -ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. - -Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. - -Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. - -`@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one). diff --git a/.changeset/storage-file-read-tenancy-posture.md b/.changeset/storage-file-read-tenancy-posture.md deleted file mode 100644 index 73289f24a7..0000000000 --- a/.changeset/storage-file-read-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -The storage download door derives the tenancy posture before resolving the caller - -`buildFileReadAuthorizer` resolved every gated download with `resolveAuthzContext({ ql: engine, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran at this door. Its headers come from the real request, so `x-api-key` is accepted, and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture a key stamped with an organization its owner had left therefore authenticated for downloads and was judged by the ownership and record-reachability checks — checks evaluated for a principal the wall should have refused at the door. - -The posture is now read off the kernel's `tenancy` service, per download, and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Under `isolated` and `group` an ex-member's stamped key is now refused and no download capability is minted; an organization-less key is refused under `isolated` and stays admitted under `group`, whose union scope makes it legitimate. Under `single` nothing changes. Patch rather than minor: no accept set widens, and a declared guard returns to enforced. diff --git a/.changeset/strand-verdict-survives-bookkeeping-throw.md b/.changeset/strand-verdict-survives-bookkeeping-throw.md deleted file mode 100644 index 06fd07ab70..0000000000 --- a/.changeset/strand-verdict-survives-bookkeeping-throw.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -Keep a resume's `status: 'stranded'` verdict when the bookkeeping after the repair journal throws. - -`resumeInternal`'s catch arm journals the consumed suspension — the snapshot `restoreConsumedSuspension` puts back — and only then stamps `status: 'stranded'`. Two statements sat between them and could throw out of the whole arm: `recordLog`'s terminal run-summary line, and a store whose `recordTerminal` throws synchronously (the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection). `failAncestors` follows them. - -A throw in that window left the run genuinely repairable while the verdict never shipped, and every consumer derives repairability from the verdict — `plugin-approvals` computes its operator-facing `repairable` as `status === 'stranded'` — so the approvals decision door reported `repairable: false` about a run that `restoreConsumedSuspension` answers `restored: true` for. That is a false negative on a repair instruction: it tells an operator not to attempt a repair that works. - -The window is now guarded. The bookkeeping may still fail — and says so loudly, at `error`, naming the run, what did not land, and the verb that repairs the strand — while the verdict still ships. Measured: with a store whose terminal write throws, `resume` now returns `{ success: false, status: 'stranded' }` instead of throwing, the door reports `repairable: true`, and the repair verb succeeds on that same run. - -The guard opens **after** the journal, so only a run that demonstrably has a snapshot can reach the stamp: a throw from the journal itself still propagates, every exit above the consumption point still carries no status at all, and cascade-failed ancestors — which journal nothing — are untouched and still correctly non-repairable. - -⚠️ This change also makes a pre-existing fault **visible** rather than creating it. The completion path's history write sits inside the same `try` as the node-failure arm, so a run that **completed** — every node succeeded — is journalled and reported `stranded` when its `completed` history row throws, and repairing such a run **re-runs the flow**. That phantom, its repair snapshot and the double run were all measurable before this change; what changes here is only that more store failures now report the verdict instead of throwing over it, so an operator can now be told to repair a completed run. Filed as #15944, with the measurement on both trees. - -⚠️ `repairable` remains a point-in-time fact, and this change does not make it durable: the run in the case above has no terminal history row (that write is what failed), so the repair rides on the in-memory journal and a restart loses it. The verdict reports what an operator can do now, which is exactly what was being denied. diff --git a/.changeset/stranded-run-status-stamp.md b/.changeset/stranded-run-status-stamp.md deleted file mode 100644 index 167063169e..0000000000 --- a/.changeset/stranded-run-status-stamp.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -feat(automation): a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937) - -The services half of the #13937 shape-4 ruling (maintainer 2026-09-01): -`resumeInternal`'s consumption order is kept — the suspension is consumed -before downstream nodes run, which is what buys exactly-once across a crash — -and the state that order leaves behind when a downstream node throws now -carries the platform-level name #14384 put on the contract. - -`AutomationEngine.resume()` (and every engine continuation that reaches the -same catch arm) returns `{ success: false, status: 'stranded', … }` where it -returned no `status` at all. Stamped on that one exit only: the pause a -durable decision was waiting on is gone, the run is recorded `failed`, and it -can be re-armed only by the explicit operator verb -`restoreConsumedSuspension` (#13909 slice 2, already published) — never by -`resume` (which answers `RUN_NOT_FOUND`) and never automatically. Distinct -from `'failed'` on purpose: that one says the run ran and was rejected; this -one says a recorded continuation stopped mid-flight and an operator has -something to repair. The result's verdict and the restore verb are held to -agree by test: a stranded result is exactly a restorable run. - -Not changed: the run's RECORDED status (the run log, `getRun`, `listRuns`, the -durable `sys_automation_run` history row) stays `failed` — that vocabulary is -`ExecutionStatus` in `@objectstack/spec`, which the ruling did not widen; the -durable discriminator for the condition remains the snapshot the terminal row -carries. No resume semantics move for any pausing node type; shapes 2 and 3 -of the decision stay excluded. - -Also in this change, under the same ruling's exactly-once guarantee, two -repairs to how `restoreConsumedSuspension` finds a stranded run's snapshot: - -- The durable run-history row of a stranded run now records the PAUSE node in - `node_id`. It recorded the node that threw — the run's last step — and the - object store read that column back as the snapshot's node, so a restore - from the row (after a restart, or on another replica) re-armed the run at - the failed node and the next resume skipped it while reporting the run - completed. The throwing node stays in the row's step log and `error`. - Visible on the Runs surface: `sys_automation_run`'s row title and highlight - set are built from `node_id` (`titleFormat '{flow_name} · {node_id}'`), so a - stranded run's row now names the PAUSED node — the one an operator can - re-arm — where it named the node that threw; ordinary completed / failed - rows are unchanged. The `node_id` and `variables_json` field descriptions - carry this carve-out, the way `node_type`'s already did. -- The verb reads the durable row and its own per-process journal as two - witnesses of one strand instead of trusting either alone. The hot copy is - preferred when both describe the same pause (it is the verbatim object the - failure was journalled from). A row that carries no snapshot is read as - "the run moved on" only when this process's own history write landed — - the replica that stranded a run used to keep a hot copy that could re-arm - the run after another replica had restored, resumed and finished it, and - the next resume re-ran every node after the pause. A snapshot the object - store could not persist (over its 256 KiB row budget) is now recorded in - the row as dropped, with the pause it belonged to, so the replica holding - the hot copy still restores and any other replica is refused with a reason - that names the budget and the remedy. - -In-memory and store-less deployments observe no behaviour difference. On the -object store, same-replica restores re-arm the pause node on every path, and -restores from the row alone do too; restores across replicas of a run that -finished elsewhere are refused. diff --git a/.changeset/structural-condition-shape-refused.md b/.changeset/structural-condition-shape-refused.md deleted file mode 100644 index a357b8759b..0000000000 --- a/.changeset/structural-condition-shape-refused.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. - -`evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. - -- The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. - -**This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. - -**Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. - -An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. diff --git a/.changeset/studio-object-field-ref-refusal.md b/.changeset/studio-object-field-ref-refusal.md deleted file mode 100644 index bbed1c08b0..0000000000 --- a/.changeset/studio-object-field-ref-refusal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. - -`list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all. - -The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this. - -- **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`). -- **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default. -- **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier. -- **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing. - -## Migration - -**A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has. - -To fix a dangling reference, do one of: - -- rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or -- drop the entry from the list. - -`os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them. diff --git a/.changeset/subflow-bubble-stranded-parent.md b/.changeset/subflow-bubble-stranded-parent.md deleted file mode 100644 index dc14066cb6..0000000000 --- a/.changeset/subflow-bubble-stranded-parent.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -automation: a subflow parent left STRANDED by a failed up-bubble is reported at `error`, not `warn` - -When an approval (or any pause) sits inside a subflow child, resuming the child -bubbles up to the parent. If the parent's own continuation then fails on the -engine's stranded exit — its suspension consumed, a repair snapshot journalled, -the run recorded `failed` — nothing but a `warn` said so, while the child's -resumer (an approvals decision door, a wait timer) was told the resume -succeeded. Persisted state and runtime state disagree and nothing looks broken -from the outside, which is the durability class. - -`bubbleToParent` now grades that record by the engine's own -`AutomationResult.status` discriminator: `'stranded'` is reported at `error`, -naming the parent run and the `restoreConsumedSuspension` verb that repairs it. -Every other parent-resume failure — a concurrent resume, an unreachable store, -a thrown resume — stays at `warn` unchanged, on a narrower ground: those exits -carry no `'stranded'` discriminator. `'stranded'` is the one exit that journals -a repair snapshot, so it is the one an operator can act on, and grading by the -engine's own verdict is what keeps `error` readable. - -⚠️ That is a statement about what this seam can KNOW, not a guarantee that -every other exit left the parent healthy. Two exits are known not to be: - -- a **thrown** parent resume carries no discriminator at all, and #15555 - documents a window in which a throw between the journal and the stamp hides a - parent that IS stranded. Left at `warn` deliberately, for that card; -- the **claim-path** store failure reports, in its own envelope text, that - whether the suspension was consumed is UNKNOWN — it relies on a retry to - settle it, and an up-bubble has no retrier. ("Not consumed" is the guarantee - of the strict-load store failure only, not of every store failure.) - -⚠️ This is the log half only. What the child's resumer is told is unchanged. diff --git a/.changeset/summary-backfill-recompute-undefined-on-empty.md b/.changeset/summary-backfill-recompute-undefined-on-empty.md deleted file mode 100644 index 4ab5fa0ed8..0000000000 --- a/.changeset/summary-backfill-recompute-undefined-on-empty.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/cli": minor ---- - -feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) - -A roll-up value has three producers — the insert-time seed, the child-write -recompute, and the one-off backfill — and **declaring a summary field on an -object that already has rows reaches none of them**. For `count`/`sum` the -backfill repairs that as a side effect (every `NULL` is a hole to it). For -`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the -function alone, so "never computed" and "no child rows" were indistinguishable, -the column stayed `NULL` on every pre-existing parent, and the report said -`filled: 0` — a false all-clear that a timed flow built on the column then -turned into "matches nothing" (the customer case behind cloud#1908). - -**What changes** — maintainer ruling on #15064, option A: the caller who holds -the fact gets a way to say it; the predicate and the default run do not move. - -- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field` - roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is - walked like a `count`: every `NULL` parent is recomputed through the same - `aggregateSummaryValue` the engine writes. A parent whose aggregate is the - empty-set reading (`null` — no child rows) already holds the engine's own - value, so it is neither counted as a hole nor written; the scoped run is - therefore idempotent in the same "re-run until it reports zero" sense. - Naming a `count`/`sum` is accepted and changes nothing, so a publish path can - pass every column it just declared without knowing the empty-set list. -- A name that resolves to no roll-up owned by an object the run walks — a typo, - a plain field, or an object `objects` left out — is **refused before any row - is read**, dry run or apply, with an ADR-0112 envelope (`code: - 'INVALID_FIELD'`, `status: 400` — the code the projection and write axes - that name a field already answer, while sorting keeps `INVALID_SORT`; - `field` names the first unresolved entry, `fields` all of them). A silent - no-op there would be the same false all-clear this option exists to end. -- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement - of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an - unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'` - to every roll-up function, since a named `max` now appears in `fields`. -- `os migrate summary-nulls --recompute-undefined-on-empty object.field` - (repeatable) passes the scope through; the confirmation prompt names the - columns; `formatSummaryBackfillReport` lists them under "Recomputed on - request" and explains a `NULL` that remains. - -**What does not change:** without the option the walk, the writes, every -counter and the human-readable report are byte-for-byte what they were (pinned -against output captured on `main` before this change); `min`/`max`/`avg` stay -out of scope and keep being reported under `skippedUndefinedOnEmpty`; the -predicate `summaryNullIsBackfillable` is untouched, so `os migrate -summary-nulls` keeps its meaning on every deployment. The only visible delta on -an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`. - -`minor` for both packages: an optional parameter on a published exported -function, a new report key, and a new CLI flag are each a purely additive -widening of a published surface, which takes at least `minor` (bump-level rule, -2026-09-04); the `fix`-shaped motivation does not lower it. diff --git a/.changeset/suspended-run-cache-consumed-elsewhere.md b/.changeset/suspended-run-cache-consumed-elsewhere.md deleted file mode 100644 index 1475441cb7..0000000000 --- a/.changeset/suspended-run-cache-consumed-elsewhere.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms (#15832) - -`AutomationEngine` had exactly one eviction site for its `suspendedRuns` -map, inside `forgetSuspendedRun` — and that runs in whichever process -**consumes** the suspension. In a multi-replica deployment that is routinely -not the process that parked it: replica A parks a run, replica B resumes it, -and nothing ever removes A's entry. There is no invalidation channel from B -to A. - -The card that found this located the leak on `resumeInternal`'s -`claim.kind === 'lost'` branch, which returns before that choke point. That -branch does leak, but it is not the common shape: the **no-race** variant -leaks identically — A parks, only B ever resumes, A never attempts a claim -and there is no `'lost'` anywhere in the sequence — so an eviction hung on -`'lost'` alone would have left the ordinary deployment untouched. - -The retained snapshot was **not only memory**. Two readers handed it back: -`listSuspendedRuns()` (synchronous, cache-only, and the one listing on the -`AutomationService` spec contract) and `listSuspendedRunsDurable()` (which -deliberately appends map entries the durable list lacks). Once the other -replica **completed** the run, both reported a phantom — a finished run -listed as suspended, whose `getSuspendedScreen()` answers `null`, so a -consumer that listed and then opened got an entry it could not act on. - -An entry is now dropped whenever this process holds a store-authoritative, -per-id "no row" answer for it: the strict loader's store miss (which reaches -`resume`, `hasSuspendedRun`, `cancelRun` and `getSuspendedScreen`), a lost -advance claim, and a bounded per-id reconcile for the map-only entries of -`listSuspendedRunsDurable()`. - -**Nothing here moves the cache-only listing's contract.** The fix only ever -*removes* entries. The spec says `listSuspendedRuns()` lists "the currently -suspended (paused) runs awaiting a resume"; the engine's own docblock adds -only that it may OMIT runs (those parked in a previous process lifetime), -because it reads the cache alone. Under-reporting is therefore already -inside the declared latitude, and over-reporting was never inside the -promise. Neither listing becomes store-backed, and `listSuspendedRuns()` -stays synchronous. - -Three shapes are deliberately **never** evicted, each pinned by a control: -no store attached (the map IS the authority); a run whose durable save -failed (`cacheOnlySuspensions` — the store was never handed the row, so its -silence says nothing about it); and a store read that THROWS (an outage -means the run's existence is unknown, not gone). A failed `list()` -enumeration likewise triggers no per-id reconcile — during an outage that -would ask about every live run in the process. - -**Residual, stated rather than implied.** Eviction is demand-driven: a -phantom is cleared when this process next obtains the per-id answer for that -run — any `resume` / `hasSuspendedRun` / `getSuspendedScreen`, or a -`listSuspendedRunsDurable()` reconcile. A process that never looks at the -run again keeps the entry until it does. With no invalidation channel -between replicas, closing that last gap needs either a background sweep or a -store-backed listing, and both are decisions above this change; the boundary -is pinned by a `RESIDUAL` test rather than left to be discovered. - -Note 2 of the same card — the `'unsupported'` branch deciding on the shape of -a value the conditional delete has **already** been issued to obtain — is -**not** addressed here: its honest fix is a declared return contract for the -engine's multi-row delete, which lands in another package. diff --git a/.changeset/sys-email-error-description-widen.md b/.changeset/sys-email-error-description-widen.md deleted file mode 100644 index e27ce70c96..0000000000 --- a/.changeset/sys-email-error-description-widen.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): `sys_email.error` field help now covers pre-delivery rejections, not only transport failures - -`sys_email.error` was declared as *"Transport error message when status=failed"*. -Since `EmailService.recordRejectedMessage` landed, the same column also carries -the reason a message was rejected by `normalizeMessage` **before** it reached a -transport (an unsendable `from`, no recipient, no subject, no body) — those rows -are written with `status: 'failed'` too, prefixed `rejected before delivery: `. - -Nothing was misleading in the *data*: the row prefixes its own reason, so an -operator reading a failed row is never sent chasing an SMTP host for a message -that never reached one. What was stale was the field's declared `description`, -which Studio surfaces as the field's help text — it named only the transport -case, narrower than what the column has held since that change landed. - -The description now reads: *"Why the message failed — a transport error, or the -validation that rejected it before delivery."* It stays true under both row -shapes and deliberately does not name the row's own `rejected before delivery:` -prefix, so it will not go stale again if that prefix's wording changes. diff --git a/.changeset/sys-email-highlight-fields-to-addresses.md b/.changeset/sys-email-highlight-fields-to-addresses.md deleted file mode 100644 index 9caa18e9bb..0000000000 --- a/.changeset/sys-email-highlight-fields-to-addresses.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -`sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629) - -The list read `['subject', 'to', 'status', 'sent_at']`. Three of those four resolve; `to` does not — `sys_email`'s recipient column is `to_addresses`. It now reads `['subject', 'to_addresses', 'status', 'sent_at']`, and nothing else about the object moved. - -`highlightFields` is the object's ordered "most important fields" pointer (ADR-0085): it drives the default list columns, record cards, previews and the detail highlight strip. Every consumer **silently skips** an entry it cannot resolve — nothing throws and nothing logs — so each of those surfaces rendered one field short, and the field missing from the platform's own outbound-email log was the recipient. - -There was a second, louder consequence that nobody could reach by accident. Since `object-field-ref-unknown` crossed onto the object write door (#15254), this body could not be republished through `PUT /api/v1/meta/object` or a package publish: the door answers `422 INVALID_METADATA`. `sys_email` reaches the runtime as a code-shipped registry object instead — `EmailServicePlugin` hands it to the manifest service, a path that runs no authoring gate — so boot was never affected and no deployment was failing. It was a trap laid for whoever next edited the object through a door rather than the file. - -`sys-email.highlight-fields-resolve.test.ts` pins it through that real door rather than by comparing the array against `Object.keys(fields)`: it runs `runRuntimeAuthoringRules({ type: 'object' })` over the shipped declaration with the audit module's other objects as resolution context, and a control case restores the old entry and requires the same call to refuse it — so a green result means the door read this object and accepted it, never that nothing looked. diff --git a/.changeset/system-duration-keys-unit-in-key-name.md b/.changeset/system-duration-keys-unit-in-key-name.md deleted file mode 100644 index c77b197fab..0000000000 --- a/.changeset/system-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) - - - -**BREAKING** — fifteen published `system/` duration keys are renamed and -tombstoned. Shipped as `minor` under the repo's launch-window convention for -breaking changes; the hand-migration prescriptions are registered under protocol -major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, -「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, never only in its `.describe()` prose, and grandfathers no -existing offender. Stack card 1/6 (#15676) landed the rule's two structural -exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared -`kernel/`; this card clears `system/`. Measured with the gate itself: -`src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls -**22 → 7**. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `CacheTier.ttl` | `ttlSeconds` | seconds | -| `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | -| `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | -| `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | -| `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | -| `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | -| `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | -| `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | -| `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | -| `StorageConnection.timeout` | `timeoutMs` | milliseconds | -| `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | -| `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | -| `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | -| `Span.duration` | `durationMs` | milliseconds | -| `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | - -**Every value is unchanged** — only key names move, and every default moves with -its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to -300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, -`RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so -`syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything -under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` -at the authoring site (input type `never`) and fails the parse with the rename -prescription rather than a bare unrecognized-key error. - -## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them - -`AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. -Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare -name under an `externalVocabulary` marker. - -The asymmetry is the whole point. Every bucket-CORS standard the first value is -forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, -Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation -from* the cited standard rather than a mirror of it. The Fetch response header -the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. -A find-and-replace across both leaves no gate red: the marker exempts the twin -either way. A pin test in `object-storage.test.ts` is the only guard. - -## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` - -The gate prints `sizeSeconds` for the two `window.size` keys, and that name is -wrong on its face. `size` means a byte or row count everywhere else in this spec -— `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and -`MetricExportConfig.batch.size` on the very same file is a record count — so -`sizeSeconds` would have kept the misleading half of the name and bolted a unit -onto it. `windowSeconds` was rejected for a plainer reason: the parent key is -already `window`, so it would read `window.windowSeconds`. - -`durationSeconds` names what the number is, and the file supplied its own -precedent: `ServiceLevelObjective.period.duration` already called a period -length a duration. After the rename all three read alike. The prescription says -so explicitly, so the next author does not read the departure as a slip and -"correct" it back to the mechanical name. - -## Dispositions — eight semantic entries, no D2 conversion - -Justified per key rather than defaulted, and this card's answer is uniform: -**none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs -over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, -`disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or -`worker` root — none of these twelve defs is a stack collection member or a -registered metadata kind stored as a `sys_metadata` row, so the conversion chain -has no seam that would see one. They are host configuration (`CacheTier`, -`FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, -`QueueConfig`), call arguments (`CollaborationSessionConfig`) and -runtime-emitted measurements (`Span`). Each therefore carries a **semantic** -entry, which is what ruling B prescribes for a key that is not authorable stack -metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, -nested spellings included. - -## Keys deliberately left alone - -`FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS -resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. -`CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is -milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must -not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a -record count and `QueueConfig.rateLimit.max` is a task count: neither is a -duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s -burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit -anywhere in their prose, so both are outside the gate's population entirely. -Pin tests assert each of these, so a later sweep cannot read this card as -"every duration-shaped number on these files". diff --git a/.changeset/tidy-cups-smile.md b/.changeset/tidy-cups-smile.md deleted file mode 100644 index 9741d78089..0000000000 --- a/.changeset/tidy-cups-smile.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': minor -'@objectstack/service-automation': patch -'@objectstack/lint': patch -'@objectstack/spec': patch -'@objectstack/service-settings': patch ---- - -**BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. - -Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. - -- `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. -- `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. -- Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. -- `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. -- `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. - - diff --git a/.changeset/today-offset-one-calendar.md b/.changeset/today-offset-one-calendar.md deleted file mode 100644 index 60babce8b2..0000000000 --- a/.changeset/today-offset-one-calendar.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition. - -The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back). - -The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none. - -The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition. - -Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question. diff --git a/.changeset/translation-actions-convention-docblock-keys.md b/.changeset/translation-actions-convention-docblock-keys.md deleted file mode 100644 index 54b9abb1b6..0000000000 --- a/.changeset/translation-actions-convention-docblock-keys.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Documentation: the `_actions` and `globalActions` convention lists in `translation.zod.ts` now name every key the schema accepts. - -Both docblocks are hand-written prose copies of what one factory declares. `actionTranslationSchema(...)` builds both surfaces — the file says so outright, "Shared by object `_actions` and `globalActions`" — so the two lists carried the identical four addresses (`label`, `confirmText`, `successMessage`, `resultDialog.*`) and the identical two omissions. An author reading either list to learn which keys exist saw a strict subset of what the schema has accepted all along. - -Added to both lists, in the order the factory declares them and matching the spelling already landed in `i18n-resolver.ts`'s own header: - -- `description` — the explanatory line under the title in the action's param dialog, resolved at `objects.._actions..description` with a `globalActions..description` fallback. -- `params..{label, helpText, placeholder, options.}` — the per-parameter translations for an action's param dialog. - -Prose only. No schema, factory or resolver changed: the keys were already declared and already accepted, so nothing about what a bundle validates to moves. `packages/spec` publishes `src/**/*.zod.ts`, which is why documentation-only text still ships and still earns a changeset. diff --git a/.changeset/translation-liveness-group-boundary.md b/.changeset/translation-liveness-group-boundary.md deleted file mode 100644 index 88205381d0..0000000000 --- a/.changeset/translation-liveness-group-boundary.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: `translation`'s `_note` states the live/planned boundary instead of a hand-maintained total - -The header claimed "11 of 12 groups live; the twelfth, `datasets`, …". No reading of the -file's own `props` produces that pair. Measured on this commit, `props` holds fourteen -entries — the eleven translation groups `translationDataShape()` declares, plus `locale` -and the item-identity keys `name` / `label` — of which exactly one row, `flows`, is not -`live`, and `datasets` is one of the groups rather than a twelfth. Counting all of `props` -gives thirteen live of fourteen; counting groups only gives ten of eleven. Neither is -eleven of twelve. - -This is the second wrong total the same sentence has carried. It previously read "10 of 11 -groups live; the one dead group (`validationMessages`) …", describing a group removed in -17.0.0 (#4667) — prose outliving its subject in the header of the very file whose rows warn -about that. So the integers are deleted rather than re-derived, on the #7377 precedent that -moved this ledger family's other hand-maintained counts out of prose and into a generated -artifact: the sentence now names the BOUNDARY ("every group but `flows` is live"), which the -per-prop rows below it carry and `state-counts.md` totals, and it records why a total taken -over `props` is not a total of groups. Both former totals are kept, quoted, as the -sentence's own correction record. - -Published data, prose only: `liveness/` is in this package's `files` array, so these ledgers -ship in the npm tarball. No `status` value moves, no schema changes and no gate verdict -changes — every non-`live` row in the file, at every nesting level, is `flows` or one of its -children. diff --git a/.changeset/tree-reference-self-only.md b/.changeset/tree-reference-self-only.md deleted file mode 100644 index a5eceee52d..0000000000 --- a/.changeset/tree-reference-self-only.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention). Maintainer ruling 2026-09-05 on #14892, option A. - -**What changes.** `ObjectSchema` (and `ObjectExtensionSchema`, judged against -the object it extends) now refuses a field declared `type: 'tree'` whose -`reference` names any object other than the declaring one. The refusal is a -located parse issue at `fields..reference` whose message names both -objects and the three ways out: drop `reference` (it is optional on a `tree`), -name the object itself, or declare a `lookup` if a link to a different object -was meant. `FieldSchema` alone is unchanged — a field does not know which -object declares it, so the judgment lives on the object door. - -**Why.** A hierarchy is parent/child within one object, and that is what every -reader of the type already assumed: the tree renderer's parent-pointer -auto-detection takes the first `tree` field as the object's own parent column, -four prose surfaces said self-reference, and `deleteBehavior` materialises on -`tree` beside `lookup` because a self-referential hierarchy is a relation whose -cascade is exactly the intended semantics. The designer's shared `reference` -input reused one "Target object name" help text for three types, and the one -shipped `tree` example pointed at another object under a hedging label — two -spellings parsed silently, and an example taught a third. The key is now -enforced with one meaning; `reference` stays optional on a `tree` as a -redundant self-annotation, which is also what makes a reference-less `tree` -being classified `relation` (and materialising `deleteBehavior`) coherent. - -**Alongside.** `checkViewCompleteness`'s parent-pointer predicate reads the -same rule: a `tree` field is a detectable parent pointer only when its -`reference` is absent or the object's own name, so a `tree` view bound to an -object whose only `tree` field points elsewhere is reported `view/tree-without- -parent-field` rather than blessed. The designer help text for the shared -`reference` row now says so for `tree`, the showcase `showcase_field_zoo.f_tree` -is a self-reference, and the data-modeling docs say "optional and, if given, -must be this object". - -```ts -// accepted — a self-reference, or no reference at all -parent: { type: 'tree', reference: 'category' } -parent: { type: 'tree' } -// refused at parse — `fields.parent.reference` on object `category` -parent: { type: 'tree', reference: 'department' } -``` - -**Not measured.** Out-of-repo cross-object trees are NOT MEASURED: no customer -application was surveyed for a `tree` field pointing at a different object. -In-repo, every other `tree` author is a self-reference or carries no -`reference`; the objectui pin's unit fixtures are outside this schema's reach -and are listed on the card. diff --git a/.changeset/truthful-stranded-decision-envelope.md b/.changeset/truthful-stranded-decision-envelope.md deleted file mode 100644 index 1f8a3facda..0000000000 --- a/.changeset/truthful-stranded-decision-envelope.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/types": minor -"@objectstack/plugin-approvals": minor -"@objectstack/rest": minor ---- - -An approval decision that lands while its flow run strands now says so in fields, not only in prose. - -`POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. - -The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: - -- **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. -- **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. -- **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. - -`@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. diff --git a/.changeset/try-catch-error-value-code-key.md b/.changeset/try-catch-error-value-code-key.md deleted file mode 100644 index 0fbbeed2da..0000000000 --- a/.changeset/try-catch-error-value-code-key.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954) - -`TryCatchErrorValue` — the ONE shape the catch region's author, the engine and the run log share for the value a `try_catch` binds to `errorVariable` (default `$error`) — gains an optional `code: string`: the platform-classified error code (ADR-0112) the failing node's own result carried, e.g. `create_record`'s `DUPLICATE_RECORD`. The engine has bound it since `@objectstack/service-automation`'s #14419 change; the schema was a plain `z.object` that did not declare it, so a round-trip through the declared shape silently STRIPPED the key the engine had put there, and the generated reference page documented four keys where the runtime binds five. The `errorVariable` description on `TryCatchConfig` names `code` too, so the authorable surface documents branching on `$error.code`. - -Typed as an open `string`, deliberately not `StandardErrorCode` and not the ledger union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and `NodeExecutor` is third-party-registrable, so a closed type would be false the moment anyone registers an executor that throws its own code. The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP door; this value is bound in-process and never crosses one. - -Additive and optional: every value that parsed before parses byte-identically, and a binding without a classified code still carries no `code` key — absent means "no classified code", never "nothing failed". Semver: a new optional key on a published schema widens the accept set and the exported `TryCatchErrorValue` type without retiring or renaming anything ⇒ `minor`; no ADR-0087 entry is owed because there is nothing an upgrader must migrate. diff --git a/.changeset/typed-expression-envelope-dialect.md b/.changeset/typed-expression-envelope-dialect.md deleted file mode 100644 index a1f6128cb6..0000000000 --- a/.changeset/typed-expression-envelope-dialect.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a typed expression slot fixes its dialect on the envelope arm too, and refuses a blank string (#15028, #15035) - - - -**BREAKING** accept-set narrowing on the twelve authorable keys typed -`CronExpressionInputSchema` (`system/CronSchedule:expression`, -`ai/KnowledgeRefreshPolicy:cron`, `api/ScheduledExport` and -`api/ScheduleExportRequest` `schedule.cronExpression`, -`automation/ScheduleState:cronExpression`, `integration/DataSyncConfig:schedule`, -`system/CacheWarmup:schedule`, `system/BackupConfig:schedule`, -`system/DisasterRecoveryPlan` `testing.schedule`) and -`TemplateExpressionInputSchema` (`ai/PromptTemplate:system`, -`ai/PromptTemplate:user`, `data/Object:titleFormat`). Shipped as `minor` under -the repo's launch-window convention for breaking changes. Measured cost: zero -— of the 46 author values probed across the repo, the examples, the docs, the -skills and the objectui pin, every one is a bare string or a same-dialect -envelope. - -**What changes** (`packages/spec/src/shared/expression.zod.ts`): - -- The envelope arm of each typed schema is `ExpressionSchema` narrowed to that - one dialect literal. A cron-typed slot accepts a bare string or - `{ dialect: 'cron', source }` only; a template-typed slot likewise for - `template`. An envelope naming any other dialect — `cel` or `template` on a - cron slot, `cel` or `cron` on a template slot, or the retired `js` — is - refused with ONE `invalid_union` at the slot whose message is the slot's - dialect-only sentence (`TYPED_EXPRESSION_DIALECT_ONLY[dialect]`, exported). - Before, the arm was the unrestricted `ExpressionSchema`, so a cron slot - parsed a `cel` envelope green and whatever read it received an expression it - could not schedule — a copy-paste artifact of the untyped schema, never a - decision. -- The bare-string arm refuses a blank string — empty or whitespace-only, the - notion of blank `EvaluatedExpressionSchema` already applies (`source.trim()`) - — with ONE `invalid_union` at the slot whose message is the slot's - source-required sentence (`TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]`, - exported). Before, `.min(1)` did not trim, so `' '` normalized to - `{ dialect: 'cron', source: ' ' }` on every typed slot. -- The author type narrows with it: `CronExpressionInput` / - `TemplateExpressionInput` no longer admit a foreign-dialect envelope, and the - published JSON Schema and the generated reference page declare the envelope's - `dialect` as that one literal. `TypedExpressionDialect` names the pair. - -**What does NOT change.** No cron syntax is judged at parse time; `croner` -judges it where a schedule is wired (`CronSchedule.expression`, the one cron -slot with a reader); no grammar is restated in spec. `'not a cron'` still -normalizes to `{ dialect: 'cron', source: 'not a cron' }`, deliberately: the -repo's two cron grammars already disagree on 5 of 32 probed patterns, and a -restatement would be a third. `ExpressionInputSchema` and `ExpressionSchema` -are untouched — the untyped envelope still takes every declared dialect, and an -envelope with neither `source` nor `ast` is refused exactly as before. - -```ts -// a cron-typed slot, e.g. defineStack({ jobs: [{ schedule: { type: 'cron', expression } }] }) -expression: '0 9 * * 1-5' // accepted, normalized to { dialect: 'cron', source } -expression: { dialect: 'cron', source: '0 9 * * 1-5' } // accepted verbatim -expression: { dialect: 'cel', source: 'now()' } // refused at jobs.0.schedule.expression -expression: ' ' // refused at jobs.0.schedule.expression -expression: 'not a cron' // accepted — syntax is croner's verdict at schedule time -``` diff --git a/.changeset/unanswerable-target-refusal-opens-with-prose.md b/.changeset/unanswerable-target-refusal-opens-with-prose.md deleted file mode 100644 index 394ad26ac8..0000000000 --- a/.changeset/unanswerable-target-refusal-opens-with-prose.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`findReferencesToMeta`'s unanswerable-target refusal now opens with prose instead of a machine-shaped `[unanswerable_target]` tag that nothing read. - -``` -before 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} -after 501 {"error":{"code":"NOT_IMPLEMENTED","message":"References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} -``` - -Nothing else moves: same `501`, same `NOT_IMPLEMENTED`, same envelope position, and the prescriptive sentence ADR-0110 D3 requires is untouched. Callers branch on `code`, which is unchanged; only the human-facing sentence is shorter. - -Why the tag was wrong here specifically. This producer writes a bracketed tag on many refusals, and every other one is the lowercase restatement of that throw's own declared `code` — `[item_locked]` with `ITEM_LOCKED`, `[no_draft]` with `NO_DRAFT`, `[invalid_request]` with `INVALID_REQUEST`. Measured across the two producer files, 30 of the 31 tagged throw sites that declare a code restate it that way. This refusal declares `NOT_IMPLEMENTED`, so its tag was the sole exception: it named a token the envelope carries on no axis, and a repo-wide search finds no parser, no switch, no assertion and no doc that reads it. Per the ruling behind the `/data` door's `FORBIDDEN:` prefix removal, `error` is human language and `code` is the machine token. - -It became worth fixing when the `/meta/:type/:name/references` door started relaying the producer's prose verbatim: before that the whole sentence was replaced by `Internal server error` and the tag reached nobody, and after it the tag was the first thing an operator read on the screen where they decide whether to delete something. The `@objectstack/rest` entry in this release quotes the pre-removal sentence in its example; this entry is the later word on that wire text. - -The absence is now pinned in `protocol.reference-target-unanswerable.test.ts` — nothing pinned the tag, so without a pin nothing would have pinned its removal either. diff --git a/.changeset/undefined-comparand-prescription-position-safe.md b/.changeset/undefined-comparand-prescription-position-safe.md deleted file mode 100644 index 25e5aaf86b..0000000000 --- a/.changeset/undefined-comparand-prescription-position-safe.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `undefined` comparand refusal prescribes the null predicate by its ruled spellings (#14426) - -`parseFilterAST`'s comparand-type door refuses an `undefined` comparand at every -position. Its prescription read "Write null for the null predicate, or omit the -key" — position-agnostic advice that, followed at `{ $gt: undefined }`, produced -`{ $gt: null }`, which the 2026-09-01 ruling refuses one door over (and, at an -`$in` / `$nin` / `$between` member, produced the list shapes refused on -2026-08-31). Two loud refusals to reach one right answer. - -The sentence now names the null predicate by its complete spellings — -`{"$eq": null}` / `{"$ne": null}` — or omit the key, so following it never lands -in a refusal at any position the sentence is emitted at. No accept/refuse -behaviour changes: same envelope (`INVALID_FILTER` / 400), same path, same -accepted-set and NOT-applied sentences. diff --git a/.changeset/validation-message-locale-negotiation.md b/.changeset/validation-message-locale-negotiation.md deleted file mode 100644 index 0a67b9bd8e..0000000000 --- a/.changeset/validation-message-locale-negotiation.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -`accept-language: zh` now reads a Chinese refusal on the response whose labels are already Chinese. - -`@objectstack/spec` has one locale-negotiation rule — `resolveBundleLocale`: exact match, then case-insensitive, then base language, then **variant expansion**, which is the step that reaches a `zh-CN` bundle from a bare `zh`. `pickData` calls it, and every document translator (`translateObject`, `translateView`, `translateDataset`, …) goes through `pickData`. That is why an app shipping only `zh-CN` still answered `accept-language: zh` with translated object, view and dataset labels. - -The write path's message bridge was the one consumer that never negotiated. `ExecutionContext.locale` is the header's first tag verbatim — `preferredLocaleFromHeader` reports what was *asked for* and expands nothing, deliberately, because each of its callers negotiates differently — and the engine handed that tag straight to `II18nService.t()`. A served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so `zh` missed the `zh-CN` bundle and the English text came back. The result was a half-translated response an app had no way to see coming: the bundle key was present and correct and the coverage gate was green. - -`ObjectQL`'s validation-message context now resolves the requested tag against what the bridged service reports it holds (`II18nService.getLocales()`), through that same `resolveBundleLocale`. The rule is not re-implemented in the engine — the document translators ask it about a bundle's keys, and this asks it about the service's locales. Authored `objects.._validations..message` text, `validation.field.*` overrides and translated field labels all follow, because they read one locale. - -Unchanged: **which** writes are refused, and everything machine-readable about a refusal — the `code`, the `field`, the `constraint`, the status. Only the language of the sentence moves. `preferredLocaleFromHeader` is untouched, and so is every other caller of it. A request with nothing to negotiate against — no i18n service, a service that cannot report its locales, or a tag no variant of which is on offer — passes through exactly as before. - -`ObjectQL.setI18nService` accepts an optional `getLocales?: () => string[]` alongside `t`. `II18nService` has always required `getLocales()`, so every real service already satisfies it; a partial shim that omits it keeps today's behaviour rather than being negotiated against. diff --git a/.changeset/value-shape-detail-prefers-unrecognized-keys.md b/.changeset/value-shape-detail-prefers-unrecognized-keys.md deleted file mode 100644 index be2c73cfb9..0000000000 --- a/.changeset/value-shape-detail-prefers-unrecognized-keys.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -`os migrate value-shapes` now prescribes the key rename on a legacy `{latitude, longitude}` location, instead of reporting the missing-pair type error. - -A value-shape rejection was read positionally — `parse.error.issues[0]` — at both places the value-shape detail is produced: the write path's warn-first / strict branch, and the exported `valueShapeViolation` the scan imports. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a value whose keys were **renamed** the actionable message sorts last and was discarded. A `location` stored as `{latitude, longitude}` — the exact legacy shape the scan's own header names as one it exists to find — reported `Invalid input: expected number, received undefined`, leaving an operator to derive a rename that edit distance cannot reach (`latitude` -> `lat`), while `LocationValueSchema` had built the prescription and thrown it away. - -Both readers now prefer the undeclared-key issue when the rejection carries one, through a single shared helper — two readings of the same rejection drifting by one clause is how one path prescribes the rename and the other does not. The affected strings are the `os migrate value-shapes` finding `detail`, the warn-first `[value-shape]` log line, and the `invalid_value_shape` error's `detail` under strict enforcement. - -⛔ No verdict moves. The same values are flagged, the same writes are rejected or admitted, and the deployment gate opens on exactly the same evidence — only the operator-facing text changes. - -Scoped by measurement rather than by assumption: of the sixteen types these readers cover, only `location` and `address` are backed by a key-closed object schema, so only they can emit `unrecognized_keys` at all — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half. The defect reaches `address` as well as `location`: every address member being optional rules out a *missing*-member type error, but not a *wrong-typed* declared one, which still sorts ahead of the undeclared-key issue. diff --git a/.changeset/verify-reads-package-owned-collections.md b/.changeset/verify-reads-package-owned-collections.md deleted file mode 100644 index 66c861ab06..0000000000 --- a/.changeset/verify-reads-package-owned-collections.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/verify": patch ---- - -fix(verify): `os verify` no longer reports a green run over a multi-package app it measured nothing about - -Every reader in this package took the artifact's **flattened** top level and -nothing else. A multi-package app whose definitions live under `packages[]` — -the shape ADR-0130 D4's option B emits — therefore reached `deriveCrudCases` -with no objects and no datasources, and reached `rlsProbePermissionSet` and -`declaredPositionNames` with no objects and no positions. Nothing threw. The run -derived zero CRUD round-trip cases, built an empty RLS probe permission set, -minted no persona for any declared position, and printed `✓ verify passed`. - -That is the most expensive place in the platform for a false green: `verify`'s -entire job is to be the thing that notices. A missing collection is at least -missing — zero coverage dressed as a passing run is not. - -The four reads now resolve through `resolveArtifactPackageOrder` -(`@objectstack/core`, ADR-0130 D4+D5), **flattened top level first**: - -- `deriveCrudCases` — the objects it derives cases for, and the datasource-by- - name map behind ADR-0015's double write gate. Both, because objects alone - would leave a write-opted-in federated object judged against an empty - datasource map and reported read-only, i.e. skipped by a verifier that says it - covered it. -- `declaredPositionNames` — one RLS persona per declared position. -- `rlsProbePermissionSet` — the object grants and the owner-scoped narrowing - that are what make an RLS run a probe rather than a report about the object - gate. - -The top-level read still answers first and is returned untouched, so an app on -today's additive artifact gets a bit-identical answer, and a stack that declares -an empty collection (`objects: []` is truthy) still gets an empty one. Only a -top level that does not carry the key at all consults `packages[]`. A malformed -`packages` array now surfaces `resolveArtifactPackageOrder`'s ADR-0112 refusal -instead of reading as "this app declares nothing". diff --git a/.changeset/widget-measures-missing-every-family.md b/.changeset/widget-measures-missing-every-family.md deleted file mode 100644 index 97d5c88d9d..0000000000 --- a/.changeset/widget-measures-missing-every-family.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts - -`chart-measures-missing` (#15462) reported the authoring placeholder only for the chart -family, but the return that produces it is type-independent. At the `@object-ui` revision -this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683` -reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset -widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and -the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or -`pivot` widget that selects no measures renders the same placeholder — the KPI number or -the table the author declared is not drawn at all — and nothing reported it: -`table-count-only` requires `values.length > 0` before it looks, and the rules that iterate -`dimensions[]`/`values[]` are silent on an empty array by construction. - -- **New id `widget-measures-missing`** — a NON-chart declared widget type selects no - measures. Warning tier, suppressible per widget with - `suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The - message states the consequence its family actually has (the single KPI number is not - drawn / no table is rendered) and the hint names the dataset's declared measures. -- **`chart-measures-missing` is unchanged** — same id, same chart-family population, same - message and same suppression. The condition split rather than widened because "chart" - stops naming it once the population is every family, while the old id is reachable from - the package barrel (a public-surface contract) and may already be written into a board's - `suppressWarnings`. -- `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table` - is what those families are for. - -The two never double-report one widget, in the pin's own order: the measures check runs -before the dimensions one, and `table-count-only` already skips an empty selection. diff --git a/.changeset/zh-cn-dashboard-gap-source-parity.md b/.changeset/zh-cn-dashboard-gap-source-parity.md deleted file mode 100644 index 366fcd963c..0000000000 --- a/.changeset/zh-cn-dashboard-gap-source-parity.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the zh-CN `dashboard.gap` help text says what its source now says - -`metadataForms.dashboard.fields.gap.helpText` in `zh-CN.metadata-forms.generated.ts` -read 「栅格间距(Tailwind 单位)」. That was a faithful translation of the source it was -extracted against, `Grid gap (Tailwind units)` — but the source has since been rewritten -to `Space between widgets, in steps of 0.25rem (4 = 1rem)`, which deliberately drops the -CSS framework unit an app author never chose and cannot act on, and adds the magnitude -the author can size a dashboard with. - -The leaf kept the retired vocabulary and never gained the magnitude, because bundle merge -fills gaps only: a present-but-stale leaf is not a gap, so no amount of re-extraction -corrects it. It now reads 「组件之间的间距,每级 0.25rem(4 = 1rem)」 — `widgets` is -「组件」 as it is everywhere else in this bundle, the grid framing is gone exactly as it is -upstream, and the conversion is carried so a zh author can size `gap` without reading the -English. - -One leaf. `columns` is unchanged upstream, so 「栅格列数(默认 12)」 stays accurate, and -the other five leaves of this subtree were corrected separately. diff --git a/.changeset/zh-cn-metadata-forms-source-parity-21.md b/.changeset/zh-cn-metadata-forms-source-parity-21.md deleted file mode 100644 index 8d9c188c97..0000000000 --- a/.changeset/zh-cn-metadata-forms-source-parity-21.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): 21 zh-CN metadata-form leaves say what their source says - -`zh-CN.metadata-forms.generated.ts` carries 615 leaves that differ from `en` and hold no -digest in `zh-CN.source-hashes.generated.ts` — LEGACY-TRUSTED values carried in from a -pre-consolidation hand vocabulary (`e0077ea36` deleted a 746-line -`src/metadata-translations/zh-CN.ts` and imported its strings) and never reconciled -against the English the same commit range seeded. A census of all 613 (as the population -then stood) found 26 that assert something the source does not, or drop a distinct concept -the source names. Five of the 26 — the whole `dashboard` subtree — landed in `9f57f1e31`. -These are the remaining 21. - -They are not stale fills and no gate can see them: a stale fill is a byte copy of a -previous source revision, detectable by cross-locale agreement or a recorded digest, and -these are neither. Nor does re-extraction correct them — bundle merge fills gaps only, and -a present-but-wrong leaf is not a gap. - -Three defect kinds, all decided against this bundle's own usage: - -- **Asserts an input that does not exist.** `skill.sections.triggers.description` promised - 「触发关键词」 for a section holding only `triggerConditions` (`triggerPhrases` was - removed with the key); `email_template.fields.variables.helpText` promised a per-variable - 「默认值」 that `EmailTemplateDefinitionVariableSchema` does not declare; - `action.sections.advanced.description` promised 「批量」 after `bulkEnabled` was removed - from that section. -- **Names the wrong technology.** `action.fields.body.helpText` said the body is - 「JavaScript 代码」; an L1 expression is not JavaScript. It now reads - 「L1 表达式或 L2 沙箱 JS 体」 — verbatim the sibling `hook.fields.body.helpText`, which - translates the identical source sentence correctly. -- **Drops a distinct concept the source names.** `object.fields.isSystem.helpText` dropped - 「共享默认为公开」; `view.fields.filter.helpText` reduced a sentence about the shared - visual builder to 「筛选规则」; `permission.sections.identity.description` dropped both - sentences explaining how permission sets stack on profiles. - -zh-CN only: es-ES and ja-JP are untouched here. The 18 looser paraphrases the census -excluded are also untouched. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index a6039a28fc..fdeaace754 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -74,7 +74,7 @@ docker run -p 8080:8080 \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET \ -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.3.0 + ghcr.io/objectstack-ai/objectstack:17.4.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -92,7 +92,7 @@ docker run -p 8080:8080 \ -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.3.0 + ghcr.io/objectstack-ai/objectstack:17.4.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -143,7 +143,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.3.0 +FROM ghcr.io/objectstack-ai/objectstack:17.4.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -161,7 +161,7 @@ image)? The official image is nothing more than: ```dockerfile title="Dockerfile (self-built runtime, equivalent)" FROM node:22-slim -RUN npm install -g @objectstack/cli@17.3.0 +RUN npm install -g @objectstack/cli@17.4.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 45ffe164a3..5bd97108de 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -51,7 +51,7 @@ The official image is `ghcr.io/objectstack-ai/objectstack`, and its tags mirror ```bash # docker-compose.yml, or your orchestrator's manifest -image: ghcr.io/objectstack-ai/objectstack:17.3.0 +image: ghcr.io/objectstack-ai/objectstack:17.4.0 ``` On a host running the artifact directly under systemd, the same move is a file diff --git a/docker/README.md b/docker/README.md index 662511ffb6..75bd9cf7dc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`. [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)): ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.3.0 +FROM ghcr.io/objectstack-ai/objectstack:17.4.0 COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json ``` @@ -40,7 +40,7 @@ docker run -p 8080:8080 \ -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.3.0 + ghcr.io/objectstack-ai/objectstack:17.4.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -72,7 +72,7 @@ for a `file:…` path — one box only, wrong for multi-node) and MongoDB (`libsql://…` / Turso). Add one by extending the image: ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.3.0 +FROM ghcr.io/objectstack-ai/objectstack:17.4.0 USER root RUN npm install -g tedious USER node @@ -100,5 +100,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.3.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.4.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 0e73abb995..1668e74b10 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,89 @@ # @objectstack/example-crm +## 4.0.96 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [c1eafe6] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + ## 4.0.95 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 52e8f9f7e7..c7113ba71e 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.95", + "version": "4.0.96", "description": "Minimal CRM example \u2014 a smoke-test workspace that exercises the metadata loading pipeline (objects \u2192 views \u2192 app \u2192 dashboard \u2192 hook \u2192 flow \u2192 seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-multi-package/CHANGELOG.md b/examples/app-multi-package/CHANGELOG.md index 6c1bf2e69f..040fb26f8b 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,76 @@ # @objectstack/example-multi-package +## 0.0.3 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 0.0.2 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index d42021fed6..4955290209 100644 --- a/examples/app-multi-package/package.json +++ b/examples/app-multi-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-multi-package", - "version": "0.0.2", + "version": "0.0.3", "description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`", "license": "Apache-2.0", "private": true, diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index 7810529083..22f2abbc22 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,104 @@ # @objectstack/example-showcase +## 0.3.18 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [c1eafe6] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [6b66ec7] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [33e939f] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/cloud-connection@17.4.0 + - @objectstack/connector-mcp@17.4.0 + - @objectstack/connector-openapi@17.4.0 + - @objectstack/connector-rest@17.4.0 + - @objectstack/connector-slack@17.4.0 + ## 0.3.17 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 7b3ddb7abd..2b8d667cc7 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.17", + "version": "0.3.18", "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 708ff61b8a..f4f76aedc7 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/example-todo +## 4.0.96 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [a56baa2] +- Updated dependencies [c1eafe6] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [e944fdb] +- Updated dependencies [92dc937] +- Updated dependencies [29bef09] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [17f8604] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [26144c2] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] + - @objectstack/objectql@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/client@17.4.0 + - @objectstack/mcp@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/knowledge-memory@17.4.0 + - @objectstack/service-knowledge@17.4.0 + ## 4.0.95 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 0e20505d1f..7986269b5d 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.95", + "version": "4.0.96", "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 eaf309f262..0ea8a7f18b 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,98 @@ # @objectstack/example-embed-objectql +## 0.0.36 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [a56baa2] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [26144c2] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e6279dc] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] + - @objectstack/objectql@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/driver-memory@17.4.0 + ## 0.0.35 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 476208f119..8df297d12a 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.35", + "version": "0.0.36", "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 a4f64569a0..b5f3f78feb 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,65 @@ # @objectstack/hono +## 17.4.0 + +### Patch Changes + +- 1c00b01: The Hono adapter's `/auth/*` mount yields only a 404 that disclaims ownership + + `createHonoApp`'s `${prefix}/auth/*` mount forwards every request under it to the + kernel's `auth` service and, since #4117, hands the request on to the rest of the + chain when that service answers 404 — which is what keeps `/auth/me/permissions` + and `/auth/me/localization` reachable through the gated `dispatch()`. The yield + had only the status to go on, so it could not tell "I do not serve this path" + from "I serve it and the answer is 404". + + Measured on a real boot through this adapter (a real kernel with `AuthPlugin`, + `prefix: '/api/v1'`), `GET /api/v1/auth/delete-user/callback?token=…&callbackURL=…` + answered `404 {"message":"Not found","code":"NOT_FOUND"}` from better-auth and + `200 {}` on the wire. `plugin-auth`'s route ledger carries that route under its + `disabled` disposition precisely because it is published and answers 404, so the + ledger's recorded answer was true of the auth service and false on this adapter's + wire. Nothing had to be composed in for that: the `${prefix}/*` dispatcher + catch-all this same function registers is terminal and answers `200 {}` for paths + under `/auth/`. + + The mount now asks the auth service whether its own router serves the path, via + an optional `ownsRoute(request)` — the seam `AuthManager` grew in the plugin-side + fix for the same defect — and yields only when it does not. Every answer that is + not a literal `true` (no such method, a throw, anything else) means yield, so a + service predating the method behaves exactly as before and a failure to decide + can never cost the ordering-independent surface. + + ⛔ The mount is unchanged and still claims `${prefix}/auth/*`; 401/403 were never + yielded and still are not. What narrowed is only which 404 may be handed on. +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [f1a1028] +- Updated dependencies [c1eafe6] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [bca21f7] +- Updated dependencies [2c753fe] +- Updated dependencies [fa85759] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [6615a02] +- Updated dependencies [cf9bda4] +- Updated dependencies [4db3c61] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [f7db8f4] +- Updated dependencies [3d3f60e] + - @objectstack/runtime@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index a24d20a546..e5be4d6309 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md index cf15cb8e37..6fdfd8c358 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,86 @@ # @objectstack/account +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index f84317e009..51fc18f55a 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md index 11deed6d49..7b699f6d51 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,86 @@ # @objectstack/setup +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index 8a664845d6..a9cec1b067 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md index bab2e5390d..5e47656ee8 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,86 @@ # @objectstack/studio +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 09315e787f..dce1771982 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 2b1ff39a66..2f60a87b00 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,705 @@ # @objectstack/cli +## 17.4.0 + +### Minor Changes + +- 95d5cbb: Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again. + + `minor`, not `patch`: a new subpath on a published package's `exports` map is a purely additive widening of its public surface — a new accepted key — which takes at least `minor` under the maintainer's 2026-09-04 rule (decision batch #35, on #15294) in the Check Changeset step's "WHICH LEVEL" prose; the commit type never lowers it. +- 2da2901: `os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935) + + Only an `error` failed `os lint` before. `packages/lint` ships ≈250 authoring rules, 119 of them at `warning`, and a run with any number of warnings and no errors exited 0 — so an app that wanted one of those rules to gate its CI had to re-implement it locally at error level, or bolt a script onto the JSON output to promote a family by hand. + + New public flag: **`os lint --strict`**. With it, a run with one or more `warning`-severity findings exits 1 exactly as an `error` does, and the console says why, naming the count and the flag: + + ``` + ✗ 1 warning(s) fail this run under --strict (a warning is advisory without the flag) + ``` + + `suggestion`s stay advisory under both. ⛔ The default is unchanged: without the flag the same stack still exits 0, and no existing `os lint` expectation moves. + + The `--json` face carries the verdict so a gate can read it without re-deriving it from the counts. Two keys, unconditionally present on every project-lint payload, flag or no flag: + + ```json + { "passed": false, "errors": 0, "warnings": 1, "suggestions": 0, "strict": true, "failing": 1 } + ``` + + `strict` says whether the flag was in effect; `failing` is the count the exit code was read from — `errors`, or `errors + warnings` under `--strict`; and `passed` is `failing === 0`, the same statement the exit code makes — so `--strict --json` on a warning-only stack reads `passed: false` beside exit 1, never `passed: true` next to a failing exit. + + Not in this change: per-rule severity configuration, any change to a rule's severity, and `--eval` mode, which keeps its own pass bar (`--eval-min`). +- 9c3fda5: `os lint --eval --json` now carries the ADR-0112 error carriers on its generator-load failure, instead of a bare `{error}`. + + Eval mode's `--generator` load failure was the one exit on that mode with a machine face, and it was off-envelope: the `catch` built its human message and discarded the error object, so `code` and `httpStatus` could never reach the payload. A consumer that reads `code` to branch got a real code from the same command's project-lint catch-all and `undefined` from eval mode — the case a consumer is most likely to be caught by, because the face is present and looks answerable. + + The exit now spreads `errorCodeFields(error)`, the same helper the project-lint catch-all spreads, so both failure faces of `os lint` are built from one source rather than two hand-written shapes. + + Nothing is minted. `errorCodeFields` passes a producer's code through and returns nothing otherwise — ADR-0112's ledger stays the authority on who may mint a code — so the exit is polymorphic in exactly the way its sibling already is. Measured on the command's own output, across the reachable load-failure classes: + + - a generator whose top-level evaluation throws a coded failure (an SDK refusal as the module builds its client at import) now answers `{"error": …, "code": "FORBIDDEN", "httpStatus": 403}`; both keys were being dropped; + - a file the generator reads at import that is missing now answers `code: "ENOENT"`, the errno vocabulary already documented for this command, and no invented HTTP status; + - an unresolvable path or a syntax error — esbuild's own build failure, which carries neither key — still answers a bare `{error}`, as does the hand-thrown "module must default-export a function". + + The human (non-`--json`) path, the eval report exit, and offline eval are unchanged. +- be75493: **BREAKING** `os lint --generator` now refuses to run without `--eval`, instead of accepting the flag and ignoring it. + + The flag's own description has always ended "Requires --eval.", and nothing checked it. `--generator` is read only by eval mode, so outside `--eval` the flag reached no code at all: `os lint --generator ./gen.mjs` linted the current project, exited 0 with "All checks passed", never loaded the module, and named the flag nowhere on either the human face or `--json`. A path that did not exist was accepted just as readily. Someone who meant to score a live generator got a successful-looking run whose generator was never called, with nothing said. + + The refusal is this command's own, not the argument parser's, so it keeps the shape the command's other failures already have: the reason on `error`, exit 1, and on `--json` a single JSON document with stdout still reserved for the machine. No error code is invented for it. + + A scripted invocation that passed `--generator` outside eval mode now exits 1 with the reason, where it previously exited 0 having silently skipped the generator. Eval mode itself is untouched: `--eval --generator` still loads the module and scores live output, and `--eval` alone still scores the bundled corpus offline. + + +- cee3961: **BREAKING** `os create ` now refuses a project name that npm refuses, and refuses it before it writes anything. + + `os create plugin "My App"` used to exit 0 having written `./plugin-My App/`, carrying a manifest that read `name: "@objectstack/plugin-My App"`. Nothing failed at scaffold time, so the invalid name surfaced later at `npm publish`, in the terminal of whoever ran it next. `os init` has always refused that same input before touching the disk. The rule set is now shared between the two scaffolders rather than restated in one of them, so they answer the same way. + + `os create` also refuses a name whose composed scoped package name exceeds npm's 214-character ceiling. `@objectstack/plugin-` spends 20 of those characters before the name begins, so a name that `os init` accepts can still compose to one npm rejects; that check sits next to the composition rather than in the shared rule set. + + A scripted invocation that passed an invalid name now exits 1 with the reason on stderr, where it previously exited 0 and produced a project that could not be published. + + +- cf6b671: `os create` now emits a project that installs outside this monorepo. + + Every project the command scaffolded declared its `@objectstack/*` dependencies + with pnpm's `workspace:*` protocol, extended a `tsconfig.json` two directories + above itself, and was written into this repository's own `packages/plugins/` or + `examples/` by default — so a developer following the documented command got a + project `pnpm install` refuses. The default emission is now standalone: + + - `@objectstack/*` dependencies are published semver ranges pinned to the + version of the CLI that generated them; + - the emitted `tsconfig.json` is self-contained and extends nothing; + - the project is written to `./` in the current directory (or `--dir`); + - a `pnpm-workspace.yaml` carries the build approvals a fresh `pnpm install` + needs on pnpm 11. + + The `plugin` template also emits `init` where it used to emit `initialize`. + `initialize` is not part of the `Plugin` contract, so the scaffold did not + type-check under its own `strict` config (TS7006 on the untyped `context` + parameter) and `kernel.use()` refused the plugin at load with + `Plugin init function is required` — a defect the kernel protocol docs + previously carried a warning about instead of a fix. + + The previous monorepo-internal placement is still available for ObjectStack + platform work as the explicit `--in-repo` flag, which keeps the `workspace:*` + specs and writes into `packages/plugins/` or `examples/`. +- acab609: `os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record. + + + + **BREAKING** for anything that opens the runtime state file by its old name. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file `os serve` writes under the ObjectStack home was named `runtime..json` and is now named `runtime...json`. + + `os serve` publishes `{ pid, port, url, environmentId, startedAt }` to a file under the ObjectStack home, so a supervisor can answer *"is my server running, and where?"*. That file was named `runtime..json`, and both halves of where it lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `local` environment, therefore wrote one file. + + Driven with two real boots, two project roots and one home, that produced two failures with one cause: + + - project B's boot replaced project A's record, so a reader asking about A's server was answered `pid`/`port`/`url` belonging to **B** — confidently, while A's own server was still alive and still listening elsewhere; + - project A's shutdown then deleted the file that by that point described **B**, leaving a running server with no supervision record at all. + + The file is now `runtime...json`, where the project component is a sanitised basename plus a short digest of the served app's root — the same root `serve` already resolves for host-anchored package loads. The payload is unchanged: no new key, and in particular no database path (which #15374 ruled out deliberately, because it would turn a best-effort supervision file into an identity contract). + + **If you read this file:** a reader that hard-codes `runtime..json` now gets `ENOENT` rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob `runtime.*.json` inside a home they pinned themselves (as `scripts/publish-smoke.sh` does) are unaffected. A `runtime..json` left over from an earlier version is no longer written or cleaned up by `os serve`; delete it once. + + **Which root the project component is taken from**, for a supervisor that has to reconstruct the name out of tree: it is the app root `serve` anchors at, which is the config file's own directory when that file exists and that directory carries a `package.json`, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent. + + Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here. +- 984f1da: `scoreMetadata` no longer scores a stack whose linter crashed as a perfect one. + + The metadata rubric is two halves: a schema parse and the lint sweep. When `lintConfig` threw, the scorer caught the throw and continued with `issues = []` — so the penalty was 0 and a stack half of whose rubric never ran came back as **100 / grade `A` / `valid: true`, every count zero, `issues: []`** — byte-for-byte the verdict a genuinely clean stack gets. "The linter found nothing" and "the linter never ran" collapsed into the better-looking one. + + The crash is reachable on a schema-valid stack: a localized `label` (`{ en: 'Todos', 'zh-CN': '待办' }`) on an app, or on a view's `list`, parses clean and makes the label-case rule throw a `TypeError`. That rule's crash is a separate defect, filed on its own; what changes here is that the scorer stops publishing a clean verdict it did not earn. + + A crashed lint run is now recorded in every carrier a consumer might read, because reading any one of them has to be enough: + + - **`lintError`** — a new optional string on `MetadataScore`, carrying the thrown message. Set only when the linter could not run; absent when it ran and reported errors, which is a lint verdict rather than a missing one. It reaches the CLI's published payload through `os lint --eval --json`, on `results[].score`. + - **A synthetic `error` issue** (`rule: 'rubric/lint-crashed'`, exported as `LINT_CRASHED_RULE`) — so `issues`, `counts.errors` and `valid` carry the failure too. This is what makes the eval harness fail the case: its `passed` reads `counts.errors`, and would never have seen a new field. It still fails at `--eval-min 0`, where the score alone stops discriminating. + - **`score: 0` / grade `F`** — the only channel `os lint --score --json` publishes, and the same refusal `unscorableScore()` already gives an eval case there was nothing to judge. + + The schema half is untouched and still reported: `schemaErrors` and `counts.schemaErrors` say exactly what the parse found, which was the defensible half of the original intent. +- ec0a6e7: feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) + + A roll-up value has three producers — the insert-time seed, the child-write + recompute, and the one-off backfill — and **declaring a summary field on an + object that already has rows reaches none of them**. For `count`/`sum` the + backfill repairs that as a side effect (every `NULL` is a hole to it). For + `min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the + function alone, so "never computed" and "no child rows" were indistinguishable, + the column stayed `NULL` on every pre-existing parent, and the report said + `filled: 0` — a false all-clear that a timed flow built on the column then + turned into "matches nothing" (the customer case behind cloud#1908). + + **What changes** — maintainer ruling on #15064, option A: the caller who holds + the fact gets a way to say it; the predicate and the default run do not move. + + - `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field` + roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is + walked like a `count`: every `NULL` parent is recomputed through the same + `aggregateSummaryValue` the engine writes. A parent whose aggregate is the + empty-set reading (`null` — no child rows) already holds the engine's own + value, so it is neither counted as a hole nor written; the scoped run is + therefore idempotent in the same "re-run until it reports zero" sense. + Naming a `count`/`sum` is accepted and changes nothing, so a publish path can + pass every column it just declared without knowing the empty-set list. + - A name that resolves to no roll-up owned by an object the run walks — a typo, + a plain field, or an object `objects` left out — is **refused before any row + is read**, dry run or apply, with an ADR-0112 envelope (`code: + 'INVALID_FIELD'`, `status: 400` — the code the projection and write axes + that name a field already answer, while sorting keeps `INVALID_SORT`; + `field` names the first unresolved entry, `fields` all of them). A silent + no-op there would be the same false all-clear this option exists to end. + - `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement + of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an + unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'` + to every roll-up function, since a named `max` now appears in `fields`. + - `os migrate summary-nulls --recompute-undefined-on-empty object.field` + (repeatable) passes the scope through; the confirmation prompt names the + columns; `formatSummaryBackfillReport` lists them under "Recomputed on + request" and explains a `NULL` that remains. + + **What does not change:** without the option the walk, the writes, every + counter and the human-readable report are byte-for-byte what they were (pinned + against output captured on `main` before this change); `min`/`max`/`avg` stay + out of scope and keep being reported under `skippedUndefinedOnEmpty`; the + predicate `summaryNullIsBackfillable` is untouched, so `os migrate + summary-nulls` keeps its meaning on every deployment. The only visible delta on + an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`. + + `minor` for both packages: an optional parameter on a published exported + function, a new report key, and a new CLI flag are each a purely additive + widening of a published surface, which takes at least `minor` (bump-level rule, + 2026-09-04); the `fix`-shaped motivation does not lower it. + +### Patch Changes + +- e9fcd6b: fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) + + The dashboard key catalogue `os explain` prints lists + `refreshIntervalSeconds` instead of `refreshInterval`, following the + `@objectstack/spec` rename of the authored key (the unit now lives in the key + name). Same key, same seconds; no other command output and no public surface of + this package changes. +- dacb73f: `os generate migration`: the builtin `created_at` / `updated_at` columns now match `driver-sql` on nullability and default text, and the SQL format declares itself PostgreSQL-only. + + Both formats emitted `NOT NULL` on the two audit-stamp columns while the driver creates them nullable, and the SQL format spelled their default `now()` while both knex producers emit `CURRENT_TIMESTAMP`. Nothing failed either way, but `information_schema` kept the pair textually apart forever, so a schema diff between a generated table and a platform-created one was permanently noisy. Both generators now follow the driver — the same rule the `id` column already follows — and `--format sql` states in its help text and its docblock that it targets PostgreSQL only and makes no MySQL or SQLite claim. +- c0c07ef: `os package publish --help` no longer points its local-dev example at a directory this repo does not have. + + The last line of the command's `EXAMPLES` block read: + + ``` + $ OS_CLOUD_URL=http://localhost:4000 os package publish # local dev (apps/cloud) + ``` + + `apps/cloud` was deleted from this repository — the reference cloud host now lives in `objectstack-ai/cloud` — so the parenthetical sent a reader to a path that is not in the tree they cloned. This is help text, not a source comment: it is printed verbatim to anyone who runs the command. + + The parenthetical is dropped rather than re-pointed at the other repo. The example is about `OS_CLOUD_URL` overriding the control-plane URL, which the `--server` flag already documents in the same output; which directory happens to serve `localhost:4000` was never part of what the example teaches, and a `--help` reader is not looking for a file in a monorepo. `# local dev` alone carries it, and it now matches how the CLI reference docs have long published the same example. + + No behaviour changes: `examples` is a static help string, and no flag, argument, default or exit code moves. +- ee79099: `os validate|info|diff|lint|compile|build|verify|migrate meta|i18n check|i18n extract --json` no longer print human text on stdout when the config file is missing. + + `resolveConfigPath()` emitted both of its refusals — the explicit-path miss and the auto-detect miss — through `printError` and `console.log`, **both of which write to stdout**, and then called `process.exit(1)` directly. Ten published `--json` faces reach that helper, so a missing config file answered them with exit 1, an unparseable stdout and an **empty stderr**: 206 bytes of prose on the one stream `--json` reserves for the machine. And because the exit was called rather than thrown, every command's catch-all `--json` error exit — all of which sit downstream of a throw — never ran. + + The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. Nothing else moves: + + - **the exit code is still 1**, so a consumer branching on exit status sees no change at all; + - **the wording is unchanged**, hints included, so a human reading a terminal sees the same three lines; + - **nothing is accepted or rejected differently** — no config that loaded before fails now, and none that failed now loads. + + ⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* when the config file is missing is an envelope question that touches ten published faces at once, and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. + + A new pin (`config-miss-stdout-purity.e2e.test.ts`) drives all ten faces on both branches of the helper. The existing purity pin could not: it discovers its family as the commands that call `bootSchemaStack`, and these fail before any kernel boots. +- ad0b3e7: `os diff` with no path arguments no longer prints its usage error on stdout — in either face. + + The refusal sat **above** the command's first `if (!flags.json)`, so the face was still undecided when it ran and it fired in **both**. `printError` plus three `console.log` calls — all four writing to stdout — then `process.exit(1)`. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and the streams captured separately, `os diff --json` and bare `os diff` answered byte-identically: exit 1, **141 bytes of prose on stdout, an empty stderr**, and `JSON.parse(stdout)` throwing on the one stream `--json` reserves for the machine. + + The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. The 141 bytes moved intact — stdout 141 → 0, stderr 0 → 141. Nothing else moves: + + - **the exit code is still 1**, so a consumer branching on exit status sees no change at all; + - **the wording is unchanged**, both usage hints included, so a human reading a terminal sees the same four lines; + - **nothing is accepted or rejected differently** — no invocation that worked before fails now. + + ⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* on a refusal is an open envelope question, entangled with `os lint --eval --json`'s bare `{ error }` (no `code`, no `httpStatus`), and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. + + This is the sibling of the `resolveConfigPath` repair, and a genuinely different site: that one is reached through `loadConfig()`, this one is `diff.ts`'s own usage error, raised before any config work happens. The existing pin drives `os diff` with two paths precisely so the run gets *past* this check, so it could not see this path. A new pin (`diff-usage-error-stream.e2e.test.ts`) drives the bare form in both faces, and carries a structural tripwire: across 62 command modules, 27 of which offer `--json`, `diff` was the only one with a stdout write above its guard, and the tripwire goes red if another arrives. +- 1f2a02b: `os generate migration --format sql` gives every timestamp column the time zone the platform actually stores. + + The SQL format spelled its two audit-stamp columns — and every declared `datetime` field — as bare `TIMESTAMP`. In PostgreSQL that is `timestamp WITHOUT time zone`, while both of the other producers of the same columns yield `timestamptz`. This implements **[ADR-0053](../docs/adr/0053-date-and-datetime-semantics.md) D-B4** (accepted), which governs both sites in one sentence: `Field.datetime` maps to `DATETIME(3)` on MySQL while "Postgres deliberately keeps `timestamptz`", and "the builtin `created_at`/`updated_at` take the same type — the registry declares them `Field.datetime`". So the declared-field row and the audit-stamp rows are one decision rather than two judgement calls, and `driver-sql`'s `createAuditTimestampColumn`, its `createColumn` `datetime` arm and this CLI's TypeScript migration format were already implementing it — the SQL format was the one producer that was not. + + Driven, not compiled: all three producers were run against a live PostgreSQL 16.13 and their columns read back out of `information_schema.columns`. Only the SQL format came back zone-naive, and the consequence is a data defect rather than a cosmetic type difference. A zone-naive column stores the wall clock of whatever session wrote the row and keeps nothing to recover the offset from, and `DEFAULT now()` is folded into that session's `TimeZone` on the way in. Two defaulted rows inserted **six milliseconds apart**, one under `TimeZone='UTC'` and one under `Asia/Tokyo`, were recorded **nine hours apart** in the generated table and 3 ms apart in the driver's own: + + ``` + sqlgen (timestamp) a_utc 2026-09-05 22:31:28.309421 + sqlgen (timestamp) b_tokyo 2026-09-06 07:31:28.315458 <- +9h, same instant + tsgen (timestamptz) a_utc 2026-09-05 22:31:28.31332+00 + tsgen (timestamptz) b_tokyo 2026-09-05 22:31:28.316401+00 + ``` + + The whole temporal class was enumerated in that same run and `datetime` is its only divergent member: `date` is `DATE` and `time` is `TIME` on all three producers, so neither moves. + + Two things this deliberately does not change. The audit columns' **nullability** stays as it is: the driver leaves both nullable and both generators say `NOT NULL`, nothing fails either way, and the driver's own audit DDL is dialect-branched in a way a Postgres-flavoured generated migration does not reproduce — so which side moves is a ruling, recorded in `generate-builtin-id-column.pin.test.ts` and still open. The `DEFAULT now()` spelling stays too: it is the same instant as the driver's `CURRENT_TIMESTAMP` (both are `transaction_timestamp()`) and only reads differently in the catalog. + + Scope for an existing project: already-generated migration files are checked-in artifacts and are not rewritten, and no deployed column is altered — a table created from an older generated migration keeps `timestamp without time zone` until its owner migrates it. What changes is what the next generated migration says. +- 8644d1d: `os generate migration` gives a table's own `id` column the shape the platform actually creates. + + Both migration generators hardcoded the primary key as a UUID — `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()` in the SQL format, `table.uuid('id').primary().defaultTo(db.fn.uuid())` in the TypeScript one (the default format). The platform's SQL driver emits `table.string('id').primary()`, which is knex's `varchar(255)`. A platform id is a string, not a uuid, so on Postgres the generated table refused the platform's very first insert with `22P02 invalid input syntax for type uuid`. + + The quieter half is the `DEFAULT`, and it is why this was worth correcting rather than working around. The driver emits no database-side default at all — its insert path always supplies the id itself — so `gen_random_uuid()` never fired for a platform write, only for an out-of-band one, handing that row a 36-character uuid this platform's id generator would never mint. One table would then hold two incompatible id shapes, with nothing said. + + Both generators now emit the driver's own answer: `"id" VARCHAR(255) PRIMARY KEY` and `table.string('id').primary()`. The correction also closes a contradiction inside the generator file, whose prose already stated that a reference column takes the width of the target's `id` column *because* the driver emits `table.string('id').primary()` — a few hundred lines above the two lines that emitted `uuid`. + + `generate-builtin-id-column.pin.test.ts` reads the width from the driver's own `DEFAULT_STRING_VARCHAR_CHARS` rather than transcribing `255`, so the generators cannot drift away from the driver again without a named failure. +- 51d59e4: `os lint` / `os i18n check` stop reporting a written inline locale map as an untranslated string. + + `I18nLabelSchema` authorizes two forms of a display label: a plain string, whose translations live in a bundle, and an **inline locale map** — `{ en: 'Members', 'zh-CN': '成员' }` — written out at the authoring site and picked at render time. Rulings on both forms make the map the one localisation route for props that have no bundle key at all, so a page localised that way is fully localised. + + The coverage walk could not see it. `inlineText()` narrowed a map to `undefined` — the same value an **absent** prop produces — so one diagnostic carried two opposite facts, and the gate reported a prop written out in four languages exactly as it reports a prop nobody wrote: + + - with no bundle entry, the key was dropped from the expected set entirely: neither covered nor missing, invisible in the counts; + - with a bundle entry for one locale, the key came back with no inline evidence, and every locale the **map** held and the bundle did not was reported `missing translation` — about text that was right there in the file. + + An entry now carries a third axis beside `sourceValue` and `inline`: `inlineLocales`, the map the author wrote, verbatim. Coverage reads it per locale — a locale the map carries counts as covered, a locale it omits is reported as a gap, and the default locale is satisfied by the map the way it has always been satisfied by an inline string. The read is deliberately narrower than the renderer's: only the tag-matching limbs of the shared `resolveI18nLabel` rule count, because falling back to `en` or to the untagged `default` entry **is** what an untranslated locale looks like. + + Two things this deliberately does not do. The map is still **never extracted**: no bundle row is scaffolded for it, and no key family is added — a translator working from the locale bundle still will not find these strings, which is the cost of the form and is now stated where an author chooses it (`i18n.zod.ts`, and the extractor's own header). And no key is synthesised from a node's position in the page tree: position-addressed keys would turn a reorder of two sibling components into a silent, all-green swap of their translations. If inline maps are ever to be extracted, the recorded direction is identity first — `component.id` / `section.name` / `tabs item.value` made mandatory and gate-enforced, then the existing `pages..components..` family reused. + + Net effect on a project that authors no inline maps: none. On one that does, the gate starts telling the truth in both directions — the false `missing translation` goes, and a map that genuinely omits a locale is reported for the first time. +- fc3fb7c: `os i18n extract` reports key counts that describe the bytes it emitted, and its summary is a partition of the skeleton rather than a sum over it. + + `extractTranslations` returned `counts[locale]` as a WALK counter — `count += 1` once per expected entry, unconditionally — and the command spent it as the number of keys in the file it had just written. Under the default `--objects-only` the module holds only the `objects` sub-tree, so the two are different numbers. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: + + ``` + Skeleton summary + zh-CN 776 key(s) (of 776 expected) + 773 metadataForms key(s) + Wrote OUT/zh-CN.objects.generated.ts (776 keys) + ``` + + The file that run wrote holds **2** leaves. The true split of the 776 is 2 objects + 1 app + 773 metadata-form baseline, so the summary appended a number the 776 already contained and read as 1549 out of 776 — an operator could not derive the truth from it, and the `(776 keys)` described no file the run produced. Both lines now read off the emitted tree: + + ``` + Skeleton summary + zh-CN 775 of 776 key(s) emitted objects 2 · metadataForms 773 + Wrote OUT/zh-CN.objects.generated.ts (2 keys) + Wrote OUT/zh-CN.metadata-forms.generated.ts (773 keys) + ``` + + **What each number now means.** `ExtractResult.counts[locale]` is a leaf count of `bundles[locale]` — the whole skeleton built for that locale, taken off the tree instead of off the walk that built it. It is explicitly not the size of any one file: which sections of the skeleton become committed modules is the caller's decision. The command therefore takes every count it reports off that module's own payload, selected with `translationModulePayload` — the same function `renderTranslationModule` renders from, so the number and the bytes cannot drift apart, including for a sub-tree mode added later. Nothing subtracts one count from another at a print site: that would repair today's two modes and leave the third wrong in the same way. + + **The summary line's shape changed** from `N key(s) (of N expected) + M metadataForms key(s)` to `E of S key(s) emitted` with a per-module breakdown. `E` is what this run's modules hold together and `S` is what the locale's skeleton holds, so `E ≤ S` always and the gap is exactly the keys a flag excluded — one app label under the default `--objects-only`, and nothing at all under `--no-objects-only`. A module a flag SUPPRESSED is named in the breakdown too, with its size and the words `not emitted` that keep it out of `E`: under `--no-metadata-forms` the row reads `2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`, so the operator still sees how big the baseline they switched off is — which the old, double-counting line did tell them. + + **A module with no leaves is no longer written.** The emit gate was `counts[locale] > 0`, a property of the skeleton: on a stack whose only surface is apps, the default `--objects-only` wrote a `.objects.generated.ts` holding `{}` and announced it as 774 keys. The gate is now the module's own leaf count. + + **`--json`**: `counts` is now the leaf count of the `bundles` payload printed beside it, instead of the extractor's skeleton size. The skeleton total is unchanged and still reported, under its own name, as `totalExpected`. + + ⚠️ That is **not** the relationship `metadataFormsCounts` has to `metadataForms`, and nothing here changes the latter. `metadataFormsCounts` reports the baseline as BUILT, emitted or not: under `--no-metadata-forms` the payload carries `metadataFormsCounts: { 'zh-CN': 773 }` beside an empty `metadataForms`, deliberately, and a pin holds it there. So the payload carries two count semantics — `counts` is what was emitted, `metadataFormsCounts` is what was built. Both faces are unchanged by this note; it exists because an earlier draft of it claimed a symmetry that does not hold. + + **No committed bundle moves.** All nine extract configs in this repository run under the default `--objects-only` on stacks that do author objects, and every emitted module is byte-for-byte unchanged; `pnpm check:i18n` stays green on the committed tree. What changed is stdout, the `--json` counts, and the emission of a module that would have been empty. + + The regression pin spawns the real CLI in four flag states and compares each printed count against a structural leaf count of the module it wrote, parsed back off disk. That comparison is the thing the defect precluded: a walk counter cannot disagree with the walk, so no assertion over `ExtractResult` could have failed while the printed number was wrong by two orders of magnitude. Its `--json` case drives `--metadata-forms` in both states, because a case that drives one state of a flag cannot see what that flag does — driving it ON only is exactly how the symmetry claim above survived unmeasured into a first draft. +- f5aec38: `os i18n extract --no-metadata-forms` is honoured whatever `--objects-only` is set to, and the Studio metadata-form baseline lands in exactly one module. + + The flag gated only the `.metadata-forms.generated.ts` companion. The stack module's renderer had a third mode, `kind: 'full'`, that serialised the WHOLE `TranslationData` — the baseline included — and `--no-objects-only` selected it. So the two flags stopped being independent the moment the second one was passed, in both directions: + + - **`--no-metadata-forms --no-objects-only`** suppressed the companion and wrote the same keys into `.objects.generated.ts` instead. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: the emitted zh-CN module carried **776 leaves, of which 773 were the metadata-form baseline** the flag had just switched off (the stack's own surface is 3). Those 773 are **English** — the default locale is filled from the source labels and the metadata-form registry authors them in English — so a non-English default locale shipped the platform's English Studio strings inside its own application bundle. + - **`--no-objects-only` alone** wrote those 773 keys **twice**, once in each module. + + `--objects-only` picks the stack module's sub-tree; `--metadata-forms` decides whether the baseline is emitted at all, and it is now the only control over it **on both faces**. Both flags keep exactly the meaning their `--help` already gave them, and nothing here picks a winner between them — the overlap was in the emitter, never in the two meanings. + + `'full'` is renamed `'stack'` and omits `metadataForms`, so the module a run writes and the baseline companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776 on the fixture above — the extractor's own count, none dropped, none duplicated). ⚠️ That is a statement about the PAIR a run emits, not about "three kinds partitioning the leaves": `'objects'` is a sub-selection of `'stack'`, not a sibling of it. + + `--json`, documented as "output JSON instead of writing files", mirrors that file set: `bundles` is the stack module and a `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by the same predicate. That map is new. It exists because the first cut of this change stopped the fold on the `--json` face as well and left the baseline with no JSON home at all — measured, `--json --no-objects-only` with the flag ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`, so on that face the flag decided nothing, the mirror image of the defect this card reports. `metadataFormsCounts` reports the baseline's size in every run, as before. + + **No bundle in this repository moves.** All nine extract configs run under the default `--objects-only`, whose emitted module, export name and type signature are byte-for-byte unchanged — `pnpm check:i18n` stays green on the committed tree. A stack that DOES pass `--no-objects-only` regenerates a smaller `.objects.generated.ts`: its export keeps its name and narrows from `TranslationData` to `Omit`, and the baseline it used to duplicate is in the companion beside it unless `--no-metadata-forms` says it should not be there at all. + + **What content moves where.** On the file face nothing published loses content: under the default `--objects-only` the output is byte-identical, and under `--no-objects-only` the baseline moves out of the stack module into the companion the same command already writes — unless `--no-metadata-forms` says it should not exist, which is the ask. On the `--json` face the baseline moves from inside `bundles` to its own top-level key, and under `--no-metadata-forms` it is now absent, which it never was before: that face did not honour the flag at all. + + The regression pin spawns the real CLI and takes a group census of the bytes it wrote, and drives `--json` in BOTH flag states. The one-state version of that case could not have failed on the axis that failed here — a pin that exercises only the flag-OFF path can never detect a flag that does nothing. The sibling pin that mirrors the emit rule and checks file NAMES stayed green through all of this: the file set was right in every combination, and only the content was wrong. +- 095df7f: `os lint` and `os i18n extract` no longer count one translation key twice. + + A translation key is derived from *where a string is addressed*, not from *which declaration was being read* when the walk reached it — and two declarations can address one bundle slot. `collectExpectedEntries` emitted one entry per declaration, so a key reachable twice became two expected entries. Two families were measured, with different causes: + + - **Two carriers, one action.** The normalized config attaches an object's actions to `obj.actions` *and* to the top-level `actions` list — the same object reference, not a copy — so both action branches emitted `objects.OBJECT._actions.ACTION.*`. This is the family the coverage report shows: 70 of 691 baselined units across `app-todo` (40), `app-showcase` (29) and `app-crm` (1). + - **Two declarations, one form field.** `deleteBehavior` is declared twice in each of the `field` and `object` metadata forms, gated on `visibleWhen` (`lookup` vs `master_detail`); both render into one key. Config-independent — it duplicated six entries on every config, including an empty one. + + Neither is an authoring mistake, and neither is fixable where it originates: both are two correct declarations of one displayed string. So the walker now collapses entries that address the same path, keeping the first emission. + + What that corrects, in both directions: + + - **`os lint`'s i18n findings.** The same missing key was reported twice, byte-identically. `pnpm check:i18n-coverage` ratchets the finding *count* while its report calls the number "untranslated declared strings", so translating one key moved the ratchet by two and the frozen debt was ~11% larger than the work it described. The three coverage baselines are regenerated in this change and fall by exactly 70 (691 to 621): `app-crm` 102 to 101, `app-showcase` 443 to 414, `app-todo` 146 to 106. The ratchet's direction, monotonicity and failure text are unchanged — only the population it counts. + - **`os i18n extract`'s reported counts.** `totalExpected` and the per-locale `counts` counted emissions while the skeleton itself had already collapsed the duplicates on the way in, so extract over-reported what it wrote — 1632 claimed against 1531 keys written on `app-showcase`, 894 against 870 on `app-todo`, 930 against 925 on `app-crm`. Those numbers now match the skeleton. + + No generated bundle changes: every duplicate pair measured carries a byte-identical record, so de-duplication removes copies and never a demand. All nine `translations/*.generated.ts` packages stay in sync. +- 212eaba: `os init -t app`, `os init -t plugin` and `os g object` now emit an object file that compiles. All three wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`, so the first command a new user runs produced a project that failed its own `pnpm typecheck`. + + Measured against the **published** package a real user installs (`npm pack @objectstack/spec@17.3.0`, extracted and linked into a driven emission), not against the workspace: + + ``` + error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object' + tsc exit 2 + ``` + + Identical at TypeScript 5.3.3, 5.8.3 and 6.0.3, so it was never a compiler-version effect. `os create example` type-checked clean on the same tarball in the same run — the failure was specific to these emissions. + + The annotation is now `Data.ServiceObject`. That name was not chosen here — it is what [ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md) D1 already ruled: for a schema `XSchema`, the **bare** alias denotes the author state (`z.input`), and it is "the name documentation, examples, skills and AI authoring surfaces use for the thing an author writes". An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention — `UI.View`, `UI.Action`, `UI.Dashboard` and `Automation.Flow` are each the bare alias of their own schema — and only the object emitters had drifted off it. + + **Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout. + + The parsed-state alias is not an alternative here. Annotating the same emitted literal `Data.ServiceObjectParsed` fails all three cases with `error TS2740`, because every field literal is then missing the keys the schema supplies by default — which is exactly the author-state/parsed-state distinction ADR-0122 D2 draws. + + `content/docs/deployment/cli.mdx` taught the broken spelling too, and is corrected with them — a reader copying from the docs wrote the same uncompilable line. + + The whole emitter roster was swept rather than the three reported sites: driving every `os init` template and every `os g` generator through `tsc --noEmit` under the tsconfig the scaffolder itself writes, `Data.Object` was the only non-existent member any of them named. In particular `UI.View` and `Automation.Flow` — named alongside `Data.Object` in the docs line and explicitly not swept when this was reported — are genuinely exported, and their generators compile at exit 0. + + Why nothing caught this: both existing scaffold sweeps are runtime pins that load the emitted TypeScript through esbuild, which erases type annotations **without checking them**, so a broken annotation transpiles to byte-identical JavaScript and is invisible to them by construction. The scaffolds parsed, validated and loaded; they simply did not compile. A new pin runs the emitted projects through a real `tsc` program, with a canary that must fail with TS2694 so the harness cannot pass by resolving nothing. +- 54e2369: `os lint --eval` no longer scores a failed generation as a perfect one: a generator that throws now counts 0 toward `meanScore` instead of 100. + + The harness has always handled a throwing `--generator` by substituting an empty stack and scoring that. An empty stack is **100 / grade `A` / `valid: true`** — it has nothing wrong with it because it has nothing in it. So a live eval in which every single generation failed reported the best possible headline number: + + ``` + os lint --eval --json --generator ./throws.mjs + exit 1 · ok: false · passed: 0 · failed: 5 · meanScore: 100 + every case: score 100 · grade A · valid true · generationError "model unavailable" + ``` + + `meanScore` is the first number a human scanning that report reads, and it read perfect precisely when the model under test produced nothing. + + **What was NOT wrong: `passed`.** It carries its own guard (`!generationError && …`), so the failed cases were reported as failed and `ok` was `false` throughout. A reader who cross-read `ok`/`passed` was safe; a reader who checked the mean and moved on got exactly the wrong impression. That is the whole defect, and nothing about `passed`, `ok`, `total`, `failed` or the exit code changes here. + + The repair is the verdict the sibling failure path already used. A generator that *returns* a value nobody can walk was already scored `0 / F / valid: false`, with the reason written into the module: a stack that cannot be walked is not an empty stack, and `valid: true` for one that was never parsed is simply false. A stack that was never produced is not an empty stack either — so both now answer the same: + + ```json + { "id": "invoice_with_line_items", + "generationError": "model unavailable", + "passed": false, + "score": { "score": 0, "grade": "F", "valid": false } } + ``` + + and the run above now reports `meanScore: 0`. + + `meanScore`'s denominator is unchanged and is now stated in the payload's own documentation: the mean is over every case **attempted**, so a failed case contributes its 0 and is counted. The alternative — averaging only over cases that could be scored — is a different metric that would report the quality of the generations that arrived while staying silent about how many never did; a `meanScore` that switched denominators without saying so would be a worse defect than the one being fixed. + + No key is added to or removed from the `--json` payload, and nothing a generator can return is newly accepted or rejected: an off-shape stack is still a **scored** case whose schema errors are why it fails, never a generation error. +- 17ec4b1: `os lint --eval --json` reports an unscorable generated stack as a failed case instead of crashing with no JSON at all. + + The eval harness promised totality in writing — *"Never throws — generation failures become failed cases"* — and the promise was false as written. Its `try` wrapped only the call to your `--generator` module; the `scoreMetadata(stack)` call that follows sat outside it. So a generator that **threw** became a failed case, exactly as documented, while a generator that **returned** a value nobody could walk took the whole process down: + + ``` + os lint --eval --json --generator ./g.mjs + exit 1 · stdout 0 bytes · stderr " Error: poison getter" + ``` + + A caller that asked for `--json` got the framework's human error text on stderr and no document at all to parse. Eval mode dispatches above the project-lint `try`, so the catch-all JSON exit that mode has could never see it either. + + Scoring a stack means walking it, and there are two walks: the normalizer spreads the stack's top level, and the schema parse walks everything below it. A throw from **either** now becomes that case's `generationError` — the same per-case channel a throwing generator already used — so the report exit that was always there emits its JSON, names the cause, and still exits non-zero: + + ```json + { "id": "invoice_with_line_items", + "generationError": "Failed to score the generated stack: poison getter", + "passed": false, + "score": { "score": 0, "grade": "F", "valid": false } } + ``` + + Nothing new appears on the `--json` face: no new key, no new payload shape. The failing exit was already reachable for a throwing generator; it is now reachable for a poisonous one too. + + The failed case is scored `0 / F / valid: false` rather than as an empty stack. An empty stack scores 100 / A / valid, and stamping that on a stack nobody could parse would have put a clean-looking verdict next to a failure — the crash replaced by a quiet wrong answer. + + Unchanged: offline mode, and every off-shape stack a generator can return. Bad metadata is still **scored**, with its schema errors as the reason it fails — it is not rerouted into the failure channel. +- 135843d: `os migrate meta` no longer prints the protocol version under the word "runtime", where it read as the installed package version. + + The chain line used to end `(runtime 17.0.0)`. That number is `PROTOCOL_VERSION` — the protocol major padded to a semver — and it is not, and never tracks, the version of the installed `@objectstack/cli` or `@objectstack/spec`. On a 17.3.0 install the line appeared beside the real package versions of the same upgrade session (`npm view`, the changelog), so it read as "your runtime is 17.0.0": an apparent downgrade or a stale install, neither of which was true. + + The value was never wrong — the label and the semver form were. The line now states the fact in the protocol's own units: + + ``` + Chain: protocol 17 → 17 (this runtime implements protocol 17) + ``` + + The parenthetical is relabelled rather than dropped, because it carries a fact nothing else on screen does: when `--to` stops below this build's major, it is the only place the operator is told where the runtime actually stands (`Chain: protocol 16 → 16 (this runtime implements protocol 17)`). + + The `--json` payload is deliberately untouched: its `runtime` key still carries the same padded protocol semver. Renaming a machine-readable key is a contract change owing a reader census and a deprecation window of its own, and it is tracked separately — an e2e pin now asserts the key's current value so that move cannot happen silently. +- 5023630: The published `os` binary no longer freezes in the kernel when whatever is reading its output stops draining. + + Node puts the CLI's stderr on the non-blocking write path when it opens the pipe, so a write to a reader that has stopped is buffered rather than parking the thread. libuv clears that flag again in the pre-exec of every child spawned with **inherited** stdio — and inheriting is `dup2`, so the flag lives on an open file description the spawner shares. Clearing it for the child clears it for the CLI too. + + Measured on the built binary, `os dev --verbose` with its output piped to a reader that stopped draining: `os dev` spawns `os serve --dev` with inherited stdio at 2.8 s, that child spawns the esbuild service with inherited stderr at 5.2 s, and fd 2 stays blocking for the rest of the run. 3.1 s after the reader stopped, the main thread sat in `write(2)` (`wchan=sock_alloc_send_pskb`), 4 of 4 runs — parked 28.9 s, **ignoring SIGINT while parked**, and released only when the consumer resumed. Not a crash and not a timeout: alive, idle, unresponsive, with an empty log. Anything that pipes `os dev` and reads it slowly — a CI log collector, a backgrounded runner, a supervisor that stops draining while it does work — could park the CLI this way. + + `bin/run.js` now installs `keepStderrNonBlocking()` before oclif can write a byte. The guard re-asserts `O_NONBLOCK` immediately ahead of each write, which is what the measurement requires: the clearing that persisted was made by a **grandchild** the CLI does not spawn and cannot see, so a one-shot at startup would be undone silently and no change to the CLI's own spawn sites would have prevented it. + + The guard itself is not new — it shipped in no published install. It lived at `packages/cli/bin/stderr-nonblocking.mjs`, and `files` names only `dist`, `README.md` and `CHANGELOG.md`; npm packs a `bin` **target** regardless of `files`, which is why `bin/run.js` reached every install and the module beside it reached none. It now compiles from `src/utils/stderr-nonblocking.ts` into `dist/`, under the whitelist that was already there. + + Nothing about which arguments the CLI accepts, what it prints, or what it exits with changes. The refusal of `setBlocking(true)` in `src/utils/format.ts` stands and is untouched — this is its inverse, and what keeps its premise true. +- 7845951: `objectstack init` and `objectstack create` now read one emission policy instead of each restating it. + + Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript. + + The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`. + + Two emitted values had to survive the merge, and both are argued rather than picked: + + - **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3. + - **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two. + + **Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each. + + `npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs. +- 0c6c55e: `os serve` now says so when the SQLite file it is serving is no longer the file at its configured path. + + Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle. + + A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now). + + It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine. +- Updated dependencies [2ed6be6] +- Updated dependencies [dcad825] +- Updated dependencies [6136293] +- Updated dependencies [07f40e5] +- Updated dependencies [6573af9] +- Updated dependencies [54bb2f1] +- Updated dependencies [fd014b1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [ea03c7c] +- Updated dependencies [6530e04] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [954cb0b] +- Updated dependencies [159dbad] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [c1eafe6] +- Updated dependencies [a775510] +- Updated dependencies [60c0f61] +- Updated dependencies [65846bc] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [347b777] +- Updated dependencies [36a16d0] +- Updated dependencies [c01b3a6] +- Updated dependencies [a51eb86] +- Updated dependencies [4e090ec] +- Updated dependencies [e944fdb] +- Updated dependencies [92dc937] +- Updated dependencies [29bef09] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [236f2df] +- Updated dependencies [d30ccb9] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [a06faeb] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [dfb7a0d] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [4f85e4d] +- Updated dependencies [9c270bb] +- Updated dependencies [fa85759] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [6b66ec7] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [e1d4f9e] +- Updated dependencies [7dafaae] +- Updated dependencies [52b59d6] +- Updated dependencies [720bf47] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [6615a02] +- Updated dependencies [9f39897] +- Updated dependencies [7bf96cf] +- Updated dependencies [17f8604] +- Updated dependencies [4ca358d] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [61821e5] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [9fa5775] +- Updated dependencies [d770b3e] +- Updated dependencies [a4816a7] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [89758ac] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [46803fa] +- Updated dependencies [8a12067] +- Updated dependencies [618f70d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [33e939f] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [a646120] +- Updated dependencies [e9fcd6b] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ebb5550] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [2024eca] +- Updated dependencies [6b8c677] +- Updated dependencies [2e35765] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [b8c82de] +- Updated dependencies [0cf0867] +- Updated dependencies [5964124] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [1375344] +- Updated dependencies [ec0a6e7] +- Updated dependencies [1157e7b] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [6c439f2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [7d711c9] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] +- Updated dependencies [c550baf] +- Updated dependencies [cd55558] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/metadata-protocol@17.4.0 + - @objectstack/service-analytics@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/plugin-approvals@17.4.0 + - @objectstack/service-automation@17.4.0 + - @objectstack/lint@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/driver-turso@17.4.0 + - @objectstack/client@17.4.0 + - @objectstack/console@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/driver-mongodb@17.4.0 + - @objectstack/plugin-email@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/trigger-record-change@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/cloud-connection@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/mcp@17.4.0 + - @objectstack/plugin-sharing@17.4.0 + - @objectstack/plugin-webhooks@17.4.0 + - @objectstack/service-job@17.4.0 + - @objectstack/service-storage@17.4.0 + - @objectstack/service-settings@17.4.0 + - @objectstack/verify@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/plugin-audit@17.4.0 + - @objectstack/plugin-pinyin-search@17.4.0 + - @objectstack/plugin-reports@17.4.0 + - @objectstack/service-cache@17.4.0 + - @objectstack/service-messaging@17.4.0 + - @objectstack/service-package@17.4.0 + - @objectstack/service-queue@17.4.0 + - @objectstack/service-realtime@17.4.0 + - @objectstack/service-sms@17.4.0 + - @objectstack/trigger-api@17.4.0 + - @objectstack/trigger-schedule@17.4.0 + - @objectstack/account@17.4.0 + - @objectstack/setup@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/observability@17.4.0 + - create-objectstack@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d0ba78b18..7ed99878d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.3.0", + "version": "17.4.0", "description": "Command Line Interface for ObjectStack Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md index cc1006fc28..060ba32982 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,87 @@ # @objectstack/client-react +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e944fdb] +- Updated dependencies [92dc937] +- Updated dependencies [29bef09] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/client@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index af773183e3..a79b601ad8 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "React hooks for ObjectStack Client SDK", "main": "dist/index.js", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index aaffdbb72c..9f06bb312f 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,159 @@ # @objectstack/client +## 17.4.0 + +### Minor Changes + +- e944fdb: fix(client)!: the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312) + + **BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. + + + + Card 1 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, and better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, not the wire fact. + + ## What changed + + Four methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change: + + | method | resolved to (before) | resolves to (now) | + |:--|:--|:--| + | `client.oauth.applications.register(req)` | `any` | `OAuthApplicationRegistration` | + | `client.oauth.applications.get(id)` | `any` | `OAuthApplication` | + | `client.oauth.applications.getPublic(id)` | `any` | `OAuthApplicationPublic` | + | `client.oauth.consent(req)` | `any` | `OAuthConsentResult` | + + `OAuthApplication`, `OAuthApplicationRegistration`, `OAuthApplicationPublic` and `OAuthConsentResult` are newly exported from `@objectstack/client`. These four routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap, and none is introduced. + + ## The exact reads that stop compiling + + Everything below compiled before only because `any` is assignable to, and indexable by, everything. + + ```ts + const app = await client.oauth.applications.get('c_1'); + app.data; // was fine; now TS2339 — these routes carry NO envelope + app.anythingAtAll; // was fine; now TS2339 + + const pub = await client.oauth.applications.getPublic('c_1'); + pub.client_secret; // now TS2339 — the public projection hand-picks 7 columns + pub.grant_types; // now TS2339 — same reason + pub.disabled; // now TS2339 — same reason + + const decision = await client.oauth.consent({ accept: true }); + decision.client_id; // now TS2339 — consent answers `{ redirect, url }` + + // Timestamps are RFC 7591 NUMBERS (Unix epoch seconds), so a caller that + // guessed `Date` or ISO `string` now fails: + new Date(app.client_id_issued_at!).toISOString(); // TS2769: number is not a Date arg + app.client_id_issued_at!.slice(0, 10); // TS2339: not a string + new Date(app.client_id_issued_at! * 1000); // the correct rewrite + ``` + + A caller that only read `client_id`, `client_secret`, `redirect_uris` or `url` needs no change. + + ## Timestamps: `number`, not `Date` and not ISO-8601 + + The ruling ordered every `Date`-typed field declared as an ISO `string` and forbade both a `Date` declaration and a runtime revival layer. **This family has no `Date` field to convert.** RFC 7591 carries `client_id_issued_at` and `client_secret_expires_at` as Unix-epoch SECONDS, and the provider converts its stored `Date` to a number before serialising, so the wire sends neither a `Date` nor an ISO string. Both are declared `number`, and a type-level pin holds them there. The ruling's prohibitions are satisfied: nothing declares a `Date`, and no revival layer exists. + + ## Two places better-auth's own types were the wrong answer + + Read off the wire against a real server, not off the vendor's `.d.ts`: + + - `getPublic` is declared `OAuthClient` — the full row — but its handler hand-picks seven columns. `OAuthApplicationPublic` is that projection, derived with `Pick` so it cannot drift from its parent. Its `redirect_uris` is always `[]` on this route and carries no information. + - `user_id` and `application_type` are declared nullable by the vendor, but the serialiser folds a null column to `undefined`, so `null` is unreachable and is not declared. + + ## `oauth.applications.delete` is deliberately NOT bound + + The fifth method of the family keeps its `Promise< any >` and its ledger entry. Its route answers HTTP 200 with a zero-byte body, so its `res.json()` rejects with a `SyntaxError` on every successful delete. No annotation can be honest while that call stands, and binding it needs a behaviour change — a decision beyond this card's type-narrowing scope. That the shrink-only ledger still carries exactly this one entry is the mechanism working. + +### Patch Changes + +- 92dc937: The README's analytics and automation examples read the resolved payload. + + `client.analytics.query` / `analytics.meta` and `client.automation.trigger` stopped handing back the dispatcher's `{ success, data }` envelope in 17.0.0: each resolves to the payload itself. The README's namespace tour still showed all three as bare `await` calls with nothing reading the resolved value, so the package's own front page taught nothing about which shape comes back — neither wrong nor useful. Each of the three now assigns its result and reads one member of it: `report.rows` / `report.fields[0].name` (`AnalyticsResult`), `cubes[0].name` (the bare `CubeMeta[]`), `run.status` (`AutomationResult`) — the members those contracts actually declare, read off the payload rather than off a `data` wrapper. + + No behaviour changes; this is the README that ships inside the package. The docs site's Client SDK and Data API pages take the same treatment in the same PR. +- 29bef09: The README's namespace tour documents the `ai` surface that exists, not the three methods v17 removed. + + `client.ai.nlq` / `.suggest` / `.insights` were deleted in 17.0.0 (#3718) — and no server in any repo ever mounted `/api/v1/ai/{nlq,suggest,insights}`, so they 404ed for the whole life of the namespace. The README's "AI Services" example still showed all three. Because `files` ships `README.md` inside the tarball, that example is the package's npm front page: a TypeScript reader copying it gets TS2339 on three properties that are not on `client.ai`, and a JavaScript reader gets a runtime `TypeError`. + + The block now shows the surface the client really exposes — `ai.chat` (with a read of `answer.content` / `answer.usage`), `ai.complete`, `ai.models`, `ai.conversations.list`, `ai.agents.chat`, `ai.pendingActions.list` — every call type-checked against the package's own published `dist/index.d.ts`. It also names the condition a reader will otherwise hit unexplained: the AI routes are served by `service-ai` (a Cloud/EE package), and an environment without it answers 501 rather than 404, with the remedy discovery reports under `services.ai`. + + No behaviour changes. `patch` rather than no changeset because the README is a published file of this package, so correcting it changes what `@objectstack/client` ships; the docs site's Client SDK page already carried this correction and is untouched here. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 7495670cd9..de67ac3a0f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Official Client SDK for ObjectStack Protocol", "main": "dist/index.js", diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md index 9065017f42..bd258e8d56 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,104 @@ # @objectstack/cloud-connection +## 17.4.0 + +### Patch Changes + +- 6b66ec7: Fix: the marketplace install-local routes now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply at these doors. + + Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran here, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. + + The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage and answers 503 rather than admitting the caller. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [c1eafe6] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 035dedc3d3..a25b5b7508 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (ADR-0008): the hub service, plan policy, and entitlements stay server-side.", "type": "module", diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md index 6285b514ce..449d31d1ff 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/connector-mcp +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 0aa6fada47..fa347f1c4d 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).", "main": "dist/index.js", diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md index 2cf5cc8ea3..c2c08f969c 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/connector-openapi +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 2fb927e1a7..6a7b4bb28d 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 6bae321440..9d5c074be1 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/connector-rest +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 579e448dfc..9cc8182acb 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", "main": "dist/index.js", diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md index e679c05976..d654beb06a 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/connector-slack +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 476fa3b7eb..52ac1b1b6d 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).", "main": "dist/index.js", diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index 91cb8d2cae..555e96cc24 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/console +## 17.4.0 + +### Minor Changes + +- 236f2df: Console (objectui) refreshed to `a472b07167a3`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 15 releasing of 18 changesets added across 29 non-merge commits; omitted: 3 release-nothing changesets, 11 commits carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — Converge the lookup/user widget metadata on the spec's camelCase — one concept, one spelling (objectui#7155, maintainer ruling A′ of 2026-09-03, director decision batch #19). (objectui `351eb3181`) + - **minor** — **BREAKING** — One authority for `KanbanSchema` / `KanbanColumn` / `KanbanCard`: the bare names now belong to `@object-ui/plugin-kanban` (objectui#6172, closing the cross-package half of objectu… (objectui `2c71482ea`) + - **minor** — Retire `ComponentInput.inputType` — the fifth and last key objectui#5905 named (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-31, option B). (objectui `1ec291c0d`) + - **minor** — `@object-ui/core` publishes `resolveRecordSourceObjectName`, the ONE reader for "which object is this block bound to" (objectui#7627). (objectui `b041b9c0c`) + - **minor** — **Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the dashboard-root `title` member (objectui#7623). (objectui `5d0876c5c`) + - **minor** — **BREAKING** — BREAKING (`@object-ui/components`): the chart primitives — `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle` and the `Char… (objectui `7bf244bea`) + - **minor** — ListView: fold `data={{ provider: 'object', object }}` onto `objectName`, and read the author's view kind from `specType` / `type` (objectui#7477 — step 6 of #2890, released by th… (objectui `00d2fa682`) + - **minor** — Retire the dashboard-**root** `title` read across all five surfaces (objectui#7509, maintainer ruling 2026-09-04, decision batch #29, option C, under ADR-0049). (objectui `1cca678ba`) + - **minor** — **BREAKING** — Re-home the breakpoint layout vocabulary and delete the two dead responsive implementations (objectui#7580, maintainer ruling 2026-09-04, option A). (objectui `e62c44e7e`) + - **minor** — `@object-ui/types/zod`: the zod const `StylePropsSchema` is renamed to `ClassNameStylePropsSchema` (objectui#5928). **The old name is gone** — there is no deprecated alias and no… (objectui `24e027e93`) + - **patch** — Fix `extractToc` eating the underscores out of a `SCREAMING_SNAKE` heading, so its `#id` links resolve to the heading they name again (objectui#7667). (objectui `a472b0716`) + - **patch** — Remove `src/ui/toast.tsx`, an unreferenced primitive, and the dependency only it imported (objectui `2f61238b9`) + - **patch** — Fix `extractToc` deleting tag-shaped text that lives INSIDE an inline code span, so its `#id` links resolve to the heading they name again (objectui#7658). (objectui `90c6d090d`) + - **patch** — A record-page URL now names the object the clicked rows actually came from, in `ObjectTree` and `ObjectCalendar` (objectui#7638). (objectui `2ce2612df`) + - **patch** — fix(app-shell): the object-field options editor no longer drops `default` and `visibleWhen` on save (objectui `97c3e1972`) + + ⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 11 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644) (#7657) (objectui `f7cf7e8a9`) + - _(no changeset)_ docs(plugin-chatbot): document chatbot-floating's seven declared inputs keys (objectui#7594) (#7656) (objectui `8e501cb97`) + - _(no changeset)_ docs(agents): record the never-approve seat rule beside the governed never-list (#7630) (objectui `2e99852ca`) + - _(no changeset)_ refactor(examples): drop the inert root `title` from six catalog dashboards (#7634) (objectui `46cde8264`) + - _(no changeset)_ docs(check-skill-examples): drop the stale zero-jsonc-fences claim (#7631) (objectui `0b24d7f85`) + - _(no changeset)_ docs(governed-guard): replace the retired sha pin with the ruled approval-record predicate (#7616) (objectui `11edab88f`) + - _(no changeset)_ docs(skills): split multi-document JSON fences, drop the `...` elisions, mark every parsing fence (#7608) (objectui `89d6adf37`) + - _(no changeset)_ fix(scripts): judge spec citations at member granularity, and stop the header teaching a retired filter (objectui#7513) (#7617) (objectui `d28d87bf4`) + - _(no changeset)_ fix(governed-guard): an authorised approval record satisfies the queue leg on any commit (#7606) (objectui `0d8fd7ce3`) + - _(no changeset)_ chore(deps): Bump fumadocs-core from 16.14.4 to 16.15.4 (#7059) (objectui `1bae75bb8`) + - _(no changeset)_ docs(claude-md): collapse the two AGENTS.md excerpts to rule + hook + pointer (#7600) (objectui `c70ebaaeb`) + + + + objectui range: `00d3f09c500c...a472b07167a3` + ## 17.3.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index f0d7d6a926..8e4edce2a2 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.3.0", + "version": "17.4.0", "description": "Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 5fc45598f2..f326f1664f 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,405 @@ # @objectstack/core +## 17.4.0 + +### Minor Changes + +- 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + + A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. + + Two changes, and neither moves what a rule evaluates to: + + - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. + - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. + + `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. +- cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + + `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. + + The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. + + What changed: + + - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. + - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. + - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. + + A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. + + `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. +- cc00df2: feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) + + + + **ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in + `MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion, + and so not the metadata migration the ruling excludes. The class has no spec schema + and never had one, so there is no authorable key to tombstone with `retiredKey()` + and no stored `sys_metadata` row a conversion could rewrite: a scanner was + constructed per call and every result lived in a per-instance Map discarded with the + object, so `applyConversionsToStoredItem` has no seam that would ever see one. An + entry is nevertheless owed rather than optional, because this changeset carries a + real consumer prescription — the enforced channel is tsc at the import site, and for + any consumer it does not reach, the ledger and the generated upgrade guide are the + only channel there is. Same disposition as `contracts.IDataDriver.findStream` and + `actor-user-roles-to-positions`. + + **BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, + together with its two companion types `ScanTarget` and `SecurityIssue`. Landing + as `minor` under the repo's launch-window convention for breaking changes. + **There is no replacement**, and none is planned. + + ⚠️ **The out-of-repo consumer population for these three exports is NOT + MEASURED.** This changeset can state only what was measured *inside* the + sources this repo can read: zero constructors in objectstack, zero in objectui + at the pinned sha, and zero in the deleted example itself. How many published + consumers of `@objectstack/core` import the class is unknown — no download, + dependent or source telemetry was consulted. Read the removal as breaking for + an unmeasured population, not as a removal proven to break nobody. + + ## Why it was removed rather than repaired + + The class was a shell that reported success. `scan()` composed five private + scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, + `scanConfiguration`) allocated an empty issue array, logged, and returned it + with no code in between — none could report a finding for any input. The fifth, + `scanDependencies`, ran a real loop but matched only against an in-memory + vulnerability database whose sole writer, the public `addVulnerability`, had + zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing. + The database was therefore empty on every code path that has ever executed, so + no issue was ever produced, the score stayed 100, and the result was + `status: 'passed'` for every plugin the scanner was ever handed — a malicious + one included. + + A security control that cannot fail is worse than no security control, because + callers rely on it. Repair — writing a real vulnerability scanner — was refused + by name: it is a feature with a design surface and no demand, not a defect fix. + + ## FROM → TO + + ```ts + // FROM — compiles today, and passes every plugin it is given + import { PluginSecurityScanner } from '@objectstack/core'; + + const scanner = new PluginSecurityScanner(kernel.logger); + const result = await scanner.scan({ pluginId, version, dependencies }); + if (result.status === 'passed') { await kernel.use(plugin); } + + // TO — delete it. The condition above was always true. + await kernel.use(plugin); + ``` + + **The one-line fix:** delete the import and every call; no symbol replaces it. + If your code branched on `result.status`, take the `'passed'` branch — that is + the only branch it ever took. + + **If you were relying on it for actual security**, you were not getting any. + Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`, + Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited + third-party plugin as untrusted code. What ObjectStack does still enforce is + artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the + plugin signature verifier — "is this what the publisher signed?", never "is + this safe?"), explicit plugin **permissions**, and the sandbox **resource + limits**; all three are unchanged. + + Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of + 2026-09-05 (director summon #14, decision batch #42). The retirement is pinned + as an export-list assertion on both barrels in + `packages/core/src/security/security-scanner-retirement.pin.test.ts`. + +### Patch Changes + +- 6f94458: fix(core): narrow the operation-private-keys pin's scanner to `.ts`, so it judges exactly the population turbo re-runs it for (#15090) + + `packages/core/src/security/operation-private-keys.pin.test.ts` filtered its + candidate set with `/\.tsx?$/` — `.ts` **and** `.tsx` — while this package's + declared radius in the cross-package declaration table is a `packages/**` + subtree glob ending in `.ts`. So the pin judged a population **strictly wider** + than the one either scoping layer of `check:cross-package-test-inputs` knows + about: Layer A never unions this package into the test shard when a `.tsx` file + changes, and Layer B never moves the `test` task's cache hash for one. A `.tsx` + file under `packages/` declaring its own `OPERATION_PRIVATE_KEY_PREFIX` or + `withoutOperationPrivateKeys` was therefore scanned by the pin and invisible to + CI's scoping — landing on `main` with every PR green and then reddening whichever + unrelated PR next touched a `.ts` file. That is the #7802 shape the declaration + table exists to close, one extension wide. + + Repaired by narrowing the **scanner**, not by widening the **glob** — and that + asymmetry is measured rather than assumed. On `b548e438d`, adding a `.tsx` glob + to this package's roster entry and re-deriving `check:cross-package-test-inputs`' + watch hints flips the dispatch-gates self-test case *"nor a .tsx test file inside + it"* from true to false, with the added glob itself as the covering hint. That + case is a live specimen for "a test class the hint route cannot reach", so the + red is real and re-pointing it is a decision in another lane, not a fixup. + + What the boundary costs, measured on the pin's own surface (tracked **plus** + untracked, ignored paths excluded) at `b548e438d`: **5408** `.ts` files scanned, + 8 of them mentioning a guarded symbol; **8** `.tsx` files excluded, **0** of them + mentioning either symbol. The loss is empty today — and that reading is no longer + transcribed and trusted. A new case re-measures it on every run: it asserts the + excluded `.tsx` population is non-empty (so the boundary is an exclusion and not + an empty tree describing itself), that the filter really drops those files, and + that none of them declares either symbol. Ablation, with the restore proven by + blob hash rather than by exit code: re-widening the scanner reddens it while the + offender assertion stays green — which is precisely the failure mode, since a + wider scanner reads as coverage CI never runs — and planting a `.tsx` + redeclaration reddens it with a message that says the choice is a second-gate + trade, not a one-line widening. + + The correspondence between scanner and glob is now stated at **both** ends: the + pin's header and the declaration table's entry for this package. No published + surface moves — the only source file edited is a test. +- 6e67b86: refactor(core): the authz context's time-zone probe is now the shared value-domain predicate, not a third copy of it + + `resolve-authz-context.ts` carried a module-private `isValidTimeZone` — the + `Intl.DateTimeFormat` probe, re-stated. It was the third copy of one + definition, alongside `@objectstack/spec/shared`'s `isValueDomainMember` and + `service-settings`' own re-statement. `coerceTimeZone` now calls + `isValueDomainMember('iana_time_zone', …)` and the copy is gone. + + **No behavioural change, measured rather than asserted.** The two predicates + were run over a shared 4,058-input corpus — the zones + `Intl.supportedValuesOf('timeZone')` omits (`UTC`, `Asia/Kolkata`, + `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `US/Eastern`, `GMT`), every member of that + enumeration plus its case- and space-padded variants, refusals, `Etc/` and + offset spellings, legacy aliases, and fuzz — with **zero disagreements**, and + the same zero at the `coerceTimeZone` level. The call site's own + pre-processing (trim, stringify a non-string, refuse blank) is unchanged. + + What this buys is drift resistance, not a fix: core's time-zone acceptance now + sits under the shared pins, so a future "modernisation" to + `Intl.supportedValuesOf('timeZone')` — which would silently narrow what the + authz context accepts, since that enumeration omits this platform's own + default `UTC` — turns a test red instead of shipping. +- e9fcd6b: feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) + + + + **BREAKING** — fourteen published `kernel/` duration keys are renamed and + tombstoned. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescriptions are registered under protocol + major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, + 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. + Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, + and the whole-tree count falls **36 → 22**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `EventPersistence.retention` | `retentionDays` | days | + | `EventSourcingConfig.retention` | `retentionDays` | days | + | `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | + | `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | + | `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | + | `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | + | `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | + | `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | + | `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | + | `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | + | `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | + | `StartupOptions.timeout` | `timeoutMs` | milliseconds | + | `PluginStartupResult.duration` | `durationMs` | milliseconds | + | `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | + + **Every value is unchanged** — only key names move, and every default moves with + its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to + 365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the + authoring site (input type `never`) and fails the parse with the rename + prescription rather than a bare unrecognized-key error. + + ## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace + + **`responseTime` meant two different units on two kernel shapes.** On + `PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a + publisher promises to answer a vulnerability report); on + `PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So + `responseTime: 24` was a day on one shape and a fortieth of a second on the + other, with nothing at the authoring site to tell them apart. They land on + `responseTimeHours` and `responseTimeMs` respectively — do not let one + find-and-replace rewrite both. + + **`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision + was already costing prose: the protocol lifecycle page carried a standing + paragraph whose only job was telling the two apart. `metrics.uptime` becomes + `metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a + separate, unchanged surface and must not be renamed with it. + + A third split worth reading before you migrate: `estimatedDurationSeconds: 120` + is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent + measurements of the same package install carried two different units, and no + parse can catch a value moved between them — both bounds accept any + non-negative integer. + + ## Dispositions — five semantic entries, no D2 conversion + + Justified per key rather than defaulted, and this card's answer is uniform: + **none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs + over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, + `upgrade` or plugin-security root — none of these twelve defs is a stack + collection member or a registered metadata kind stored as a `sys_metadata` row, + so the conversion chain has no seam that would see one. They are host + construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, + `MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and + runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, + `StartupOrchestrationResult`, `UpgradePlan`, + `PackageDependencyResolutionResult`). Each therefore carries a **semantic** + entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on + one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling + B prescribes for a key that is not authorable metadata. All fourteen are + registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + + ## Keys deliberately left alone + + `EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and + `MultiVersionSupport.rollout.percentage` is a proportion — neither is a + duration, so neither has a unit to carry and both keep their names. + `RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above + the key ("Execution timeout in milliseconds"), a channel + `check:duration-unit-keys` does not read: it reads `.describe()` and + `.meta({ description })`, and this key's describe ("Maximum execution time") + names none. The gate therefore lists it among the duration-shaped keys but + deliberately does not judge it — neither an offender nor an exemption — so it is + outside this rename; that JSDoc-channel gap is filed as #15939. A pin test + asserts the key still parses bare, so a later sweep cannot read the four + security renames as "every timeout on that file". + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - + startTime`), the kernel and contracts test suites, and the hand-written + `content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now + states the collision the rename removes. + + ⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local + `PluginStartupResult` interface — a different type, carrying `startTime` rather + than any duration key. It is not a reader of this schema, it is untouched by + this rename, and the divergence between the two shapes is tracked separately. +- d4f9b2a: A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data. + + Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path. + + That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`. +- a727043: fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 + + Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an + organization its owner is no longer a member of **read and wrote that + organization's rows** on the wiring the open core actually builds. Not a silent + empty set — a GET that returned the other organization's records, and a POST + that landed a row read back from the store carrying that organization's id and + the ex-member as its creator. An organization-less key on the same deployment + read `200` with an empty set, which is the silent failure the wall exists to + replace. + + The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the + effective tenancy posture from a per-request kernel, and on the single-kernel + wiring there is no per-request kernel — so the posture was `undefined` on every + request, and both posture-conditional API-key refusals are gated on it: + `organization_required` in `api-key.ts` and `organization_membership_ended` in + `resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was + active the whole time; it compares against the caller's active organization, + and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim + — the holder's own stored claim. Enforcing the wall is what let the ex-member + through, because the one fact that would expose the ended membership was not an + input to the layer that could act on it. + + The single-kernel branch now derives the posture from a provider `rest-api-plugin` + wires to the lone local kernel's `tenancy` service, in the same shape as the + auth-service provider beside it. A host that registers no `tenancy` service is + unchanged and still admits: there is no wall on such a deployment, so there is + nothing for an organization-less key to be walled out of. A `tenancy` service + that was registered and **failed to build** is an outage and answers `503`, not + an admission — a posture that could not be read is not a posture that is absent. + + Refusals are now also said out loud on the server side, at `warn`, where each + one is decided: the key's row id (never the credential or its hash), the + principal, the organization and the reason. **The wire is unchanged** — both + refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the + body, so a holder of someone else's key learns nothing a plain 401 does not + already tell them. The operator, who previously had a key that was neither + revoked nor expired and a 401 that said nothing, now has a line to find. + + Behaviour that does not move: a current member's key on the same route still + returns its rows and still writes; a request with no credential still answers + 401; and an unknown, revoked or expired key is not a refusal at all, so a key + scanner produces no log volume. +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index aabb64291d..cd4ab73e4e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Microkernel Core for ObjectStack", "type": "module", diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md index e69af5c1aa..f80e3f0890 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,7 @@ # create-objectstack +## 17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index bb6cdb946e..8b36af71df 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.3.0", + "version": "17.4.0", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md index b586243483..b73678ab4d 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,203 @@ # @objectstack/driver-memory +## 17.4.0 + +### Minor Changes + +- e9fcd6b: feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) + + + + **BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and + `FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both + renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of + the authored keys on both persistence arms. + + Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The + option was always milliseconds — it is passed straight to `setInterval` — and the + spec's `min(100)` bound is what made the bare name dangerous rather than untidy: + 100 reads as a plausible number of seconds, so an author who guessed the unit + wrong cleared the bound, was refused nowhere, and saved a thousand times more + often than intended. + + Both persistence arms move together: `type: 'auto'` resolves to this same file + adapter and forwards the same field, so this package reads exactly one spelling + rather than two. + + ```diff + - new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) + + new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) + ``` +- 2003259: fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the same shape #13878 landed on `update()` / `upsert()` one door over, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). + + `IDataDriver` has always declared `Promise[]>`, `Promise | null>` and `Promise>` on these three doors. The emitted `.d.ts` published `Promise`, `Promise` and `Promise>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record`. They are now declared as the contract declares them. + + What this asks of a consumer holding a concrete `InMemoryDriver`: a caller that reads fields off a `findOne()` result narrows the `null` arm first — the arm the driver has always been able to answer with (`results[0] || null`) and that no caller was ever asked to handle; and a caller that leaned on `any` to read a member off a `find()` row or a `create()` result now types it, since the rows are `Record`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this. + + The parameters are deliberately untouched: `create(data: Record)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed. + + +- 1cf7392: `driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042) + + `AnalyticsQuerySchema` declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain and writes the answer into `query.timezone` before a driver ever sees it. `parseDateRangeString()` never read it: a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was accepted, warned about nothing, and answered on the UTC day. + + ⚠️ **This changes which rows a query answers for a caller already passing `timezone`.** Measured at `2026-09-06T20:00:00Z` with `timezone: 'Asia/Shanghai'`, `'today'`: + + | | window | rows selected, from the same 8 probes | + |:--|:--|:--| + | before | `[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)` — the UTC day | `06T00:00:00.000Z`, `06T15:59:59.999Z`, `06T16:00:00.000Z`, `06T23:59:59.999Z` | + | after | `[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)` — Shanghai's day | `06T16:00:00.000Z`, `06T23:59:59.999Z`, `07T04:00:00.000Z`, `07T15:59:59.999Z` | + + Four rows either way, and **two of the four are different rows**: `2026-09-06T00:00:00.000Z` and `2026-09-06T15:59:59.999Z` leave the answer (they are yesterday in Shanghai), `2026-09-07T04:00:00.000Z` and `2026-09-07T15:59:59.999Z` join it (they are today in Shanghai). Both row sets are asserted against the real `MemoryAnalyticsService.query()` entry, in the same test, so the before is measured rather than recalled. + + A query carrying **no** timezone is byte-identical to before: `zonedDateStartToUtcMs` returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so #15825's repair — the common case — is untouched, and both of its pins stay green. + + Two halves, each of which fails silently on its own and each of which is pinned: the reference timezone decides **which** calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern), and **where that day begins as an instant** (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Resolving only the first would anchor to the zone's calendar day and then cut it at UTC midnight — a window that is neither the UTC day nor the zone's. The end bound is a calendar step, never `+ 86_400_000`: on `America/New_York`, 2026-03-08 is 23 hours long. +- 5f4f1f6: `driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825) + + `MemoryAnalyticsService.query()` lowers a string `dateRange` through + `parseDateRangeString()`, and that function built its window on the **local** + calendar and rendered it as **UTC**. Two independent defects lived in it. + + **1. The window boundary was local midnight.** `new Date(y, m, d)` constructs + local midnight; `toISOString()` renders that instant in UTC. So in any process + not sitting at UTC, the `'today'` bucket was the **local** day expressed as a + UTC range. Measured 2026-09-05, with the clock at `2026-09-05T20:51Z`: + + | `TZ` | `'today'` window produced | the UTC day it should be | + |:---|:---|:---| + | `UTC` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | (agrees) | + | `Asia/Shanghai` | `2026-09-05T16:00Z` → `2026-09-06T16:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + | `America/Los_Angeles` | `2026-09-05T07:00Z` → `2026-09-06T07:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + | `Europe/Berlin` | `2026-09-04T22:00Z` → `2026-09-05T22:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + | `Asia/Kolkata` | `2026-09-05T18:30Z` → `2026-09-06T18:30Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + + That is wrong on **every day of the year**, with no DST transition needed. + + **2. The `last N ...` legs mixed two calendars.** `setDate(getDate() - n)` is + local arithmetic and `toISOString()` is a UTC rendering. `setDate` preserves + wall-clock time, so the instant moves `n × 24h` only while every local day in + the window is 24 hours long; across a DST transition it moves 23h or 25h and + the window start slips an hour. `setMonth` / `setFullYear` are the same class, + and can move it by a whole day: at `America/New_York` with the clock at + `2026-01-01T12:00Z`, `last 1 month` started at `2025-12-02T00:00Z` instead of + `2025-12-01T00:00Z`. + + **⛔ The two do not fix each other**, which is the easiest thing to get wrong + here: `setUTCDate` alone leaves the local-midnight boundary in place, and + `Date.UTC` alone leaves the arithmetic mixed. Both are repaired, each is pinned + by its own file, and each was ablated on its own to prove the separation. + + **Why UTC and not "any consistent calendar".** The rest of the platform + resolves a bare date to the UTC day — `@objectstack/core`'s `{today}` + filter-token macro builds its reference day as + `new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts when the + context carries no timezone, and `{TODAY()}` in flow templates resolves to the + UTC day (#14852 repaired the identical two-calendar shape there). Before this + change the same analytics question asked through the driver's `dateRange` and + through a flow token could select **different rows in one deployment**. UTC is + also the terminal fallback of the engine's own resolution chain + (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2). + + **What did not change.** The parser's vocabulary, its `[range, range]` + fallback, and the shape of the emitted `$match` are untouched — this is a + calendar repair, not a rewrite. `AnalyticsQuery.timezone` is still not consulted + by this path; making the range tokens timezone-**aware** is a separate and + larger question, which #14852 also declined. + + **Who sees a difference.** Any deployment whose process is not at UTC: `'today'` + and `last N ...` now select the UTC day they always claimed to, so charts built + on a string `dateRange` shift by the process offset — toward agreement with + `{today}` / `{TODAY()}` and with the same query run at `TZ=UTC`. Deployments + already running at UTC are unaffected; the two spellings are indistinguishable + there, which is exactly why CI never reddened on this. + +### Patch Changes + +- a646120: fix(driver-memory): the reference matcher's `$notContains` arm answers the predicate, not a type test, for a stored non-string value + + `match()` used to answer `{ n: { $notContains: '5' } }` with NO for `{ n: 5 }` — the arm read `typeof value !== 'string' || value.includes(target)`, so a number failed `$contains` (correct) AND its negation (wrong: for the very reason a number cannot contain the substring, it does not contain it). This package's own live mingo path admitted the row, so one filter answered two ways depending on which face was asked; on this face the failure mode was silently dropped rows. + + The arm now answers what `FILTER_TEXT_CASES`' new `score` rows declare on every face (maintainer ruling 2026-09-05 on the contract card): a stored value that is not a string never satisfies a positive text operator and always satisfies `$notContains`. The no-value cells keep their #13166 answer; nothing else in the matcher moved. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 1b7754973f..15ebe23139 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "In-Memory Driver for ObjectStack (Reference Implementation)", "main": "dist/index.js", diff --git a/packages/drivers/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md index e36b1910a5..cbf13fe8ec 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,139 @@ # @objectstack/driver-mongodb +## 17.4.0 + +### Patch Changes + +- a06faeb: fix(driver-mongodb): put the test layer in front of tsc, so the package's own typecheck reports a PASS and not a NUMBER (#14917) + + `packages/drivers/driver-mongodb`'s `tsconfig.json` excluded `**/*.test.ts`, and + its `typecheck` script is `tsc --noEmit` against that very config. Measured at + `6ed4b811af` with the dependency closure built: that program admits **0** of the + package's 30 `src/**/*.test.ts` files while all **10** of its non-test `src/**` + files ARE there, so `pnpm --filter @objectstack/driver-mongodb typecheck` + exiting 0 was a true sentence carrying no information about any test file. + + The filing's headline — that a compile-time `Equals` / `IsAny` pin here is + "checked by nothing" — is **false**, and the correction on the card is right: a + second program does compile these files. `check-type-check-coverage.mjs`'s + `remeasureProject` drops only the test glob and compares the result against its + `TEST_DEBT` ledger. Confirmed here by ablation rather than argued: a + deliberately false `Equals` pin added to `mongodb-driver.test.ts` takes that + program from 10 errors to 11, above the ledger's recorded 10, which reddens it. + The pins were never phantoms. What was true is narrower, and is what this change + closes: the only program reading this layer was a **debt ratchet** — an + instrument that reports a number and fails when the number moves, not a gate + that reports a pass. + + Gives the package the #5286 sibling shape (`packages/rest`, `runtime`, + `objectql`, `core`): a `tsconfig.test.json` with module semantics only — + `esnext` / `bundler` / `lib: ES2022`, matching how vitest actually executes + these files — strictness inherited and untouched, named by the `typecheck` + script via `check:test-typecheck`. + + Measured: **10** errors under the ratchet's shape (matching its recorded number, + and its recorded composition `TS1309 x7, TS2550 x3`, class for class), and **0** + under the split. All 10 were config-tier in full — 7 `TS1309` (`await` at module + scope in a program NodeNext compiles as CJS, because this package has no `"type": + "module"`) and 3 `TS2550` (`Array.prototype.at` against a `lib` older than + es2022). Neither class says anything about a test, and nothing was exposed + behind them: there was no unresolved-import cascade here to collapse, so there + is no `+n` term. `noUnusedLocals` / `noUnusedParameters` are live for this + package (unlike `driver-turso`, which switches both off) and neither fires. + + The `TEST_DEBT` entry (10 errors) is **deleted**, not lowered — the graduation + this ratchet's invariant requires. No `test-typecheck-debt.json` is added: + residue is 0, so none is owed (#5286, maintainer-only to open). That leaves all + 30 files unledgered, so any error any one of them gains is red on arrival. + + `check:type-source-resolution` went red from onboarding the new program (the + documented onboarding-limb case, #11490): a registry entry is added rather than + `paths`, with its numbers stated in place — 123 tsc programs / 309 pairs before, + 124 / 310 after. The single new pair is `@objectstack/objectql`, a devDependency + that no non-test file in `src/` imports. + + No runtime code changes: not one test file and not one source file is edited, so + no shipped behaviour moves — the suite reports the same 552 passed / 147 skipped + across 30 files as before. The `patch` level reflects the published + `package.json` gaining `typecheck` / `check:test-typecheck` scripts and a `tsx` + devDependency. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index ed800a419c..0cff5f31fc 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client", "main": "dist/index.js", diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md index 6a2866044b..9e87c179ab 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,146 @@ # @objectstack/driver-sql +## 17.4.0 + +### Minor Changes + +- 54bb2f1: The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) + + `$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. + + On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. + + What changed: + + - **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. + - **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. + - **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. + + `$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. +- a646120: A text operator over a column whose declared type stores no text (`Field.number` and its numeric siblings, `Field.boolean`) now compiles to the contract's declared answer on every dialect, instead of a dialect accident. + + Before: `{ score: { $contains: '5' } }` over a numeric column compiled `col GLOB '*5*'` on SQLite and coerced the REAL in its storage class's spelling (`5` as `'5.0'`, so `$endsWith: '0'` matched every row), `col LIKE $1 ESCAPE $2` on Postgres and was refused at query time with SQLSTATE 42883 (`operator does not exist: real ~~ text` — a 500 for a filter the spec accepts), and `CAST(col AS BINARY) LIKE ?` on MySQL. + + Now (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05): the positive operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) compile to `1 = 0` and `$notContains` to `1 = 1` — the same row set as every JS face, decided from the declared type at compile time because the stored value is not visible until run time. Postgres: a 500 becomes a result. The gate reads the `numericFields` / `booleanFields` registries `initObjects` and `registerExternalObject` already fill; a table this driver was never told about keeps the `LIKE` / `GLOB` it always compiled, every comparand refusal still runs first, and the constants compose with the NULL-safe rules (`$notContains` admits a NULL row already) and the `$not` rewrite. Temporal columns are untouched: their stored value IS text on SQLite, so the contract declares nothing for them. + + `driver-sqlite-wasm` and `driver-turso`'s local transport inherit this compiler. +- 2200f8e: feat(driver-sql): `update()` publishes its honest type — the contract's `Record | null`, not `any` (#14438) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 used for the same door on `@objectstack/driver-memory`). `SqlDriver.update()` was written out with an explicit `Promise` while it has always answered a missing id with `null` (`formatOutput(...) || null` on the un-rotated path, `null` once every rotation shard has been probed). `IDataDriver.update()` declares `Promise | null>`, and an explicit `any` satisfies that structurally — so the emitted `.d.ts` read `Promise` and no caller holding a `SqlDriver`, or a `SqliteWasmDriver` (which inherits the door unchanged), was ever asked to narrow. It is now declared as the contract declares it, and the protected rotation-path producer `rotatedUpdateById()` carries the same type. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members now types them. No runtime behaviour changes. + + `@objectstack/driver-sqlite-wasm` re-declares no `update` member of its own (measured on its emitted `.d.ts`), so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides the door and carries its own entry. + + +- 33e939f: Schema drift now reports a SINGLE-VALUE JSON-class column that a stale `varchar`/`text` column is holding — the population the detector could never see. + + The driver decides a field's column type with `JSON_COLUMN_TYPES.has(type) || !!field.multiple`: `createColumn` gives a json column to every JSON-class TYPE, and `isJsonField` — the read-side deserializer — asks the same question. The drift detector asked only `field.multiple === true`. So a single-value `file` / `image` / `location` / `address` / `record` / `vector` / `json` field (and the option families) sitting on a `varchar` or `text` column was written as JSON by the writer and did not exist to the differ. Because the additive sync never migrates a column's type, that column stayed wrong permanently and nothing reported it. Measured on the previous tree, one call per type: all fifteen JSON-class types the spec declares returned zero findings over a `character varying(2048)` column on `postgres` and `mysql`, while the same column under a `multiple: true` field returned one in the same run. + + The detector now reads the writer's own predicate, so the two halves can no longer disagree about which declarations get a json column. `SQLite is unchanged and still reports nothing`: its read path parses a textual column regardless of what the column calls itself, re-measured on an in-memory cell as a byte-identical round-trip between the stale column and the driver's own. + + **The remedy is offered to the array-valued half only.** `os migrate multi-value-columns` repairs a stale column by wrapping each stored value in a one-element JSON array, which is the right repair for a field whose value is a list and the wrong one for a field whose value is a scalar or an object. Findings for array-valued fields (`multiple: true`, and the inherently-multi option types) keep their message character for character, so that command keeps recovering the dialect from it and keeps working exactly as before. Findings for single-value JSON-class fields carry a message of their own that names neither the command nor its statement, explains why the automated route is withheld, and describes the by-hand conversion; the command refuses such an entry (`remedy_not_recognized`) instead of running array SQL over scalar rows. + + Also fixed by the same predicate: a single-value JSON-class field declaring a `maxLength` over a wider `varchar` column used to be reported as `narrow_varchar` at category `destructive` — inviting `os migrate apply --allow-destructive` to rewrite the column to a narrower varchar, the opposite of the repair it needs. It is now reported once, as the base-type divergence. + +### Patch Changes + +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- 61821e5: A plain unique index over existing duplicate rows no longer kills the boot with the database's raw error, and `os migrate plan` no longer calls that op `safe`. + + Declaring a column unique over a table that already holds duplicates had two very different outcomes depending on one branch in the SQL driver, and only one of them was survivable. + + - **An organization-scoped unique** (the `unique: 'organization'` default, materialised as the NULL-safe `COALESCE(organization_id, '__global__')` composite) kept the boot up: the driver logged at `error` naming the index, the constraint that is not enforced and the remedy, and the ADR-0120 D4 duplicate pre-flight reported the blocked `create_index` as `category: 'destructive'` / `severity: 'error'` with the conflicting key groups and their row counts. + - **A plain unique** — no organization key part at all, reached by an object with `tenancy: { enabled: false }` or by any explicit `unique: 'global'` — took the process down: `initObjects` threw the database's own error, which names the index and the column and no rows and no remedy, nothing reached the durability channel, and `detectManagedDrift` (what `os migrate plan` reports) classified the very same op `category: 'safe'`, `severity: 'warning'`, so `os migrate apply` and dev `autoMigrate: 'safe'` walked straight into the raw failure. + + The plain path now reaches the same posture as the scoped one: + + - **The boot survives and says what is not enforced.** `syncDeclaredIndexes` absorbs a uniqueness violation on a plain unique index the way it already absorbed one on the NULL-safe composite: the failure is logged on the durability channel (`error`) naming the index, the conflicting key groups with their row counts, the constraint that is NOT enforced, and `os migrate plan` as the way out. A non-unique index and any failure that is not a uniqueness violation still surface as before. + - **The duplicate pre-flight covers it.** The ADR-0120 D4 probe no longer skips ops whose NULL-safe column set is empty, so a plain unique `create_index` over dirty data is reported `destructive` / `error` with the same row report instead of `safe`. Nothing new probes it: the existing probe already groups by the bare columns when there is no NULL-safe key part, so both key shapes share one pre-flight rather than a second copy that can drift from the first. + + Consumers of the classification see the op move from the "Safe" group to "Destructive (requires --allow-destructive)" in `os migrate plan` and `os diff`; `os migrate apply` defers it instead of attempting it; the artifact boot gate refuses with a named destructive-drift refusal instead of crashing; and dev `autoMigrate: 'safe'` leaves it alone. Clean data is unaffected — the probe finds nothing and the index is created exactly as before. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index b6c89c50ff..55cd30d82b 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex", "main": "dist/index.js", diff --git a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md index 81cbce514d..c617c9feed 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,89 @@ # @objectstack/driver-sqlite-wasm +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [33e939f] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index a2890926f1..e97049414b 100644 --- a/packages/drivers/driver-sqlite-wasm/package.json +++ b/packages/drivers/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings", "keywords": [ diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md index 33c184df82..023f1e48a0 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,144 @@ # @objectstack/driver-turso +## 17.4.0 + +### Minor Changes + +- e9fcd6b: feat(driver-turso)!: the published connection config names its timeout's unit (#15682, ruling B on #14478) + + + + + **BREAKING** — `TursoConfigSchema`'s `timeout` is renamed to **`timeoutMs`**. The + value is unchanged: the same milliseconds, the same `min(0)` bound, the same + optionality. + + `@objectstack/spec`'s own turso contract renamed the same authored key in + #15680. This package publishes a parallel schema for the same connection config + — the Spec / Studio metadata a host reads to expose Turso configuration UI — so + until now the two declarations of one setting disagreed on its spelling. They + agree again. + + The unit was never in the key name, only in the describe prose, while + `sync.intervalSeconds` — the same shape, three keys above — already spelled its + own. One published config carrying both conventions is what made the bare name + dangerous rather than untidy: an author who has just written + `intervalSeconds: 30` has no reason to read `timeout: 30` as milliseconds, and + nothing in the schema, the type or the parse would have told them otherwise. + + The old spelling is not dropped in silence. `TursoConfigSchema` is a plain + `z.object`, so a bare deletion would have STRIPPED `timeout` and parsed + successfully. The key stays declared as a tombstone instead: `tsc` refuses it on + anything typed `TursoConfig`, and a value that reaches the parse raises a + message naming `timeoutMs` rather than a generic unrecognised-key error. + + ```diff + - TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeout: 30000 }) + + TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeoutMs: 30000 }) + ``` + + `TursoDriverConfig` — this package's TypeScript constructor option, a separate + declaration — keeps its `timeout` spelling and is untouched here. +- a646120: The remote transport compiles a text operator over a declared numeric or boolean column to the contract's declared answer, in step with the local transport. + + `RemoteTransport.buildWhereSQL` compiles filters independently of `SqlDriver` and keeps no schema, so a text operator over a `Field.number` used to compile `"col" GLOB ?` and coerce the REAL in the storage class's spelling (`5` as `'5.0'`). `TursoDriver` now hands the transport its declared-type rule (`setNonTextColumnResolver`, the same shape as the temporal `setFilterColumnSql` rule), answered from the registries `registerRemoteFieldMetadata` already fills at schema sync — so a positive text operator over such a column compiles to `1 = 0` and `$notContains` to `1 = 1` on BOTH transports (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05), instead of a dialect accident. A transport nobody handed the rule to compiles exactly as before, and every comparand refusal still runs ahead of the constant. +- 2200f8e: feat(driver-turso): the `update()` override publishes its honest type — `Record | null`, not `any` (#14438) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` overrides `update()` rather than inheriting it, and the override was written out with its own explicit `Promise` — so this package's emitted `.d.ts` re-declared the door as `any` on its own and would not have picked up the `@objectstack/driver-sql` narrowing. Both of its branches already answered the contract's type: the local branch forwards to `SqlDriver.update()` (narrowed alongside, #14438) and the remote branch passes `RemoteTransport.update()`'s `Record | null` (#14428) through the generic `formatRemoteRow`. The override now declares what it answers. A caller that read fields off the result through the `any` now narrows the `null` arm first. No runtime behaviour changes. + + + +### Patch Changes + +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [33e939f] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index c8ae28b210..0d751e049e 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas", "keywords": [ diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index 400ceab0f1..665cf47a08 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,92 @@ # @objectstack/formula +## 17.4.0 + +### Minor Changes + +- 098cbb7: `validateExpression` now refuses a non-string expression `source` through `errors[]`, instead of throwing a raw `TypeError` that wiped out the caller's located reporting. + + `validateExpression(role, input)` accepts `string | { dialect?, source? }`, and read the envelope's `source` unguarded — `if (!source.trim())`. `ExprInput` declares `source?: string`, but every production call site casts, because the value comes out of **metadata**, where a declaration is a claim about stored data and not a guarantee about it. An envelope whose `source` was present and not a string therefore threw `TypeError: source.trim is not a function` out of a validator whose own docblock promises it never throws. + + **The defect was not "it throws" — it was that it threw the wrong kind and bypassed a whole located-reporting contract.** `AutomationEngine.validateFlowExpressions` collects located findings and throws one assembled error naming the flow, the node, the slot and the source (ADR-0032 §1d); `@objectstack/lint`'s stack walk attributes every finding to the hook, sharing rule, action or field it came from. An exception raised *inside* the shared validator skipped both, so the author was handed an internal message naming none of them. Measured before the fix, on a stack whose `hooks[].condition` was `{ source: { nested: 1 } }`: the whole `objectstack validate` run died on `source.trim is not a function`. After: one located `error` reading ``hook 'gate_hook' (lead) condition``. + + The guard sits at `toSource`, the entry `validateExpression` and `inferExpressionType` share — **once**, not in each caller's own `try`/`catch`, which is the tolerant-consumer shape Prime Directive #12 forbids. `validateExpression` returns `ok: false` with one `ExprValidationError` naming what was found and both authorable forms; `inferExpressionType` answers `'unknown'`, its existing "cannot prove a type". + + **No exported symbol or signature moves** — measured by diffing the built `dist/index.d.ts` before and after: 39 exported declarations on both sides, and `validateExpression`'s declaration byte-identical. What changes is behaviour at a published entry, which is why this is `minor` rather than `patch`: an input that previously produced **no verdict at all** now produces a rejection. + + **What does not change.** Absent, `null`, empty and whitespace-only sources still read as "not authored" (`ok: true`), an `{ ast }` envelope carrying no `source` is still admitted (its admission is `ExpressionSchema`'s rule, not this entry's), and a malformed *string* still gets its own diagnostic — the brace trap, the dialect mismatch, the unknown function — never the shape refusal. No input that previously returned `ok: true` now returns `ok: false`, and none that returned `ok: false` now returns `ok: true`. + + A caller that relied on catching the `TypeError` would need to read `result.ok` instead. None does: all nine production call sites (`@objectstack/lint` ×4, its docs gate ×2, `@objectstack/service-automation` ×3) read `.errors`/`.warnings` directly, and the one call site inside a `try` (`@objectstack/mcp`'s `validate_expression` tool) has a handler-level catch that degrades to an error result and declares its `expression` parameter `z.string()`. + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index b51f0efeb9..e4bc189002 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry", "main": "dist/index.js", diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md index 055bc3a281..c1a1395fe1 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,461 @@ # @objectstack/lint +## 17.4.0 + +### Minor Changes + +- 954cb0b: feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention; the level also follows the 2026-09-04 bump ruling — + this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an + additive widening is at least `minor`). No ADR-0087 conversion: no authorable key + is renamed or retired, and the shape this refuses was never a shape any surface + offered. + + The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be + a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size` + …) is finally reachable from metadata — until now CEL was only ever asked for a + boolean. The spec half landed the contract (PR #15113); this is the half that + makes it do something. + + ```yaml + # before: written into the variable verbatim, and rendered by `notify` as + # {"dialect":"cel","source":"joinNonEmpty(...)"} + # now: evaluated — digest is "Renewal due\nInvoice overdue" + assignments: + digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' } + ``` + + - **Evaluated at run time.** The built-in `assignment` executor evaluates a + `value`-role envelope with the expression engine and assigns the result, in the + same CEL scope a flow predicate is evaluated in (one shared scope builder, so a + predicate and a value expression cannot disagree about what `rows` means). A + plain string keeps today's `{token}` interpolation, and every other literal is + still assigned as data. + - **Refused at three doors.** A malformed envelope now stops the flow registering + (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as + a located `error` finding naming the node and the author's own variable — + `config.assignments.digest` — both at `objectstack validate` and at the runtime + publish gate a Studio / REST / MCP flow write goes through + (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes: + ['flow']`). Malformed is a composition, not a fixed list: whatever + `AssignmentValueSchema` refuses in the envelope's shape — among them a missing, + empty or non-string `source`, a dialect other than `cel`, a non-object `meta` — + and then CEL that does not parse. All three doors derive that set from the same + two published validators, so none refuses a shape the executor would have run, + and a registered flow never faults for a shape those validators judge malformed. + Two shapes sit outside what either validator can judge — an `ast`-only envelope + and a whitespace-only `source` (it passes `min(1)` and reads as "not authored" + to the validator, while the CEL engine parses it untrimmed) — and those fault + loudly at run time rather than assigning a value. Both are pinned and tracked in + #15430. + - **Only the canonical map.** The ledger declares `assignment.assignments.*` and + nothing else, so the two legacy shapes the executor still normalizes — the + `assignments: [{ variable, value }]` array and the bare `{ : }` + config — keep every meaning they had, envelope-shaped values included. + `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the + array form: refusing it would break flows that register today, and that refusal + is a maintainer ruling rather than a lane's call (#15137 ask 3). + + **What changes silently, and how far it reaches.** A flow that today authors an + envelope-shaped object *as data* in the canonical `assignments` map now evaluates + it — no error on either side, a different value. The discriminator is the spec's + own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`, + in the declared map only. Data that names no `dialect`, names a non-string one, + nests the envelope one level down, or sits in either legacy shape is untouched + and byte-identical. The remaining overlap — a well-formed + `{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly + the spelling the ruling reinterprets; every near-miss the two validators can + judge now refuses loudly at registration instead of changing value in silence. +- 36a16d0: Two new widget-binding rule ids for a chart widget with an empty selection + + `validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the + `@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now + warnings, suppressible per widget with `suppressWarnings: ['']`: + + - `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or + absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures + (values) for this dataset widget." before any query runs, above every family branch, so + no chart is drawn at all. + - `chart-dimensions-missing` — a chart-family widget selects at least one measure but no + dimensions. `DatasetWidget.tsx:423` reads + `const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the + widget renders as a single KPI number and the declared chart family is silently ignored. + The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches + what actually renders. + + Warning tier rather than error for both: an empty selection is a work-in-progress state a + build must tolerate, and erroring would gate the `sys_metadata` publish path on a + half-authored widget. Neither shape is folded into `chart-config-missing` — neither is + caused by, nor repairable with, `chartConfig`, which carries presentation only. + + "Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that + the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own + `METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot` + tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview` + board's own KPI tiles, is therefore not a finding. +- c01b3a6: `chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens + + The rule id covers exactly three positions, and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so none of them can produce the data failure the messages described: + + - `chartConfig.xAxis.field` — `axisPresentation` (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis presentation **minus** its `field`. The x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`; an authored `field` re-points nothing. + - `chartConfig.yAxis[].field` — the same call, per entry. The entry keeps its slot (the count is what turns on a secondary axis) and its scale and chrome; only the binding is dropped. + - `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored entry with the derived series whose `dataKey` it equals, one per entry of `values`. An entry naming no derived series is ignored whole, so the presentation hung on it — the mark, the colour, the stack, the axis side — lands on nothing. + + The renderer pins this by name in `DatasetWidget.chartConfig.test.tsx` ("ignores an authored axis `field` and keeps the derived axis binding", "ignores an authored series and keeps one derived series per measure"). + + So the old message — "the query result will not contain it" — named a query failure that never happens, and `error` blocked a build and a Studio publish for a key that changes nothing at runtime. That is the class `widget-legacy-analytics-shape` reports at `warning` in the same file ("the dashboard renderer ignores them … a silent no-op"), and this id now carries the same tier, the same suppressibility (`suppressWarnings: ['chart-field-unknown']` per widget) and the same kind of sentence. Each message states its own consequence, because the axis positions and the series position are refused for different reasons. + + The finding is **kept**, not deleted: unlike the `chart-config-missing` over-reach this measurement came from, the metadata really is wrong — the author wrote a binding and believes it is in force. + + ## Migration + + **A publish that used to be refused now succeeds.** Ruled 2026-08-15, `validateWidgetBindings` put its whole error set on the `sys_metadata` publish door (Studio / REST `/meta` / MCP) as one "this board cannot render" reference-integrity class. That class was six ids and is now five — `chart-field-unknown` has left it. A dashboard write whose only reference-integrity problem is a refused `chartConfig` binding key is no longer a 422 `INVALID_METADATA`; it publishes, and the finding rides the non-blocking `advisories` channel on the 2xx response instead. The other five (`widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`) are unchanged. + + Same direction on the CLI: `os validate` / `os build` / `os lint` report the finding at `warning`, so a stack that used to fail the build over one of these keys now exits 0 with an advisory. If you were relying on the build to stop on it, add the key to your own gate, or fix the binding — the fix has not changed: + + - point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only); + - point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only); + - name a selected measure in `series[].name`, remembering that post-cutover (ADR-0021) result rows are keyed by the dataset's measure **name** (`sum_amount`), not the base column (`amount`). + + A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`. +- 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + + A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. + + - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. + - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. + + **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. + + An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. +- 720bf47: `flow-update-readonly-field` and `hook-api-update-readonly-field` now report a non-system **create** of a static-`readonly` field — a new **error**-severity finding that fails `os lint` / `os validate` / `os build` on a shape they used to accept. + + Both rules scanned only the update verb (`update_record`; `ctx.api…update()` / `.updateById()`) and justified the omission with the same sentence: INSERT is engine-exempt from the author-declared `readonly` strip, so a create that seeds a `readonly` column is not a no-op. The maintainer ruling of 2026-09-03 (option C, #14147) made that false — `engine.insert` now runs the same `isSystem`-gated `stripReadonlyFields` the update path runs — so a flow `create_record` without `runAs: 'system'`, or a hook body's `ctx.api.object('…').insert()` under a non-system trigger, that writes a `readonly` field became a **silent no-op**: the row lands without the column (which falls back to its `defaultValue`), the step reports `success`, and only a run-time warning names the dropped field (measured end to end in `@objectstack/service-automation`'s `create-record-readonly-drop.test.ts`). Nothing reported it at build time. This closes that scan gap (#15394). + + **What now fails that passed before.** Exactly one new shape per rule, at `error`: + + - a flow `create_record` node whose literal `fields` map writes a field the target object declares `readonly: true`, on a flow that does not declare `runAs: 'system'`; + - an L2 hook body's literal `ctx.api.object('').insert({ … })` writing such a field, on a hook that does not declare `runAs: 'system'`. + + The rule ids and severities are the update ones — one id per shape, not per verb — and each finding's message names the verb it was judged on and what actually happens to a create. Everything the rules already skipped is still skipped: a templated object name, a non-literal payload, an object outside the stack or declaring no fields, an unknown field (the unknown-field rules' question), and any `runAs: 'system'` flow or hook, because seeding a `readonly` column at create time is a system act and that write lands. + + **Deliberately not reported.** + + - No `readonlyWhen` (conditional) finding on a create, on either surface: a conditional lock is evaluated against the record being written over, which a create does not have, and the engine runs no conditional strip on INSERT ("INSERT stays exempt"). A warning there would state something false about a write that lands. + - The hook rule judges `.insert()` only, not `.create()`. The host `ObjectRepository` aliases `create()` to `insert()`, but L2 bodies run in QuickJS and the VM-side `ctx.api.object()` installs no `create` leaf — a body calling `.create()` throws `TypeError: not a function` on its first run, a loud failure rather than the silent drop this rule reports. The silence is recorded as a reasoned method exclusion (`READONLY_HOOK_METHOD_EXCLUSIONS`) and pinned. + - No create finding on a **platform object** — one declaring `managedBy`, or in the reserved `sys_` namespace. The engine's create-side strip does not judge those at all (`staticReadonlyInsertSubject`: their own ADR-0086 write guard governs them), so a finding there would describe a strip that never runs. The update verb keeps judging them, exactly as the engine's update path does. + - `validate-readonly-action-writes` is unchanged: an action body runs system-elevated by design, so its create genuinely lands. + + **Migration.** If your build reds on the new finding, the fix is one of: declare `runAs: 'system'` on the flow or hook when seeding the `readonly` column is the intent (the intended channel — `readonly` governs the end-user/API surface, not trusted system writers); remove the key from the `create_record` `fields` / `insert()` payload when it is not; or stamp it in a `beforeInsert` hook on the target object (`ctx.input. = …`), which is a server value the strip does not touch. Measured over this repository's shipped examples (`app-crm`, `app-showcase`, `app-todo`): zero in-repo flows or hooks go red — the two `create_record` nodes that target an object carrying a `readonly` field write none of its `readonly` fields, and the one flow that creates unauthenticated already declares `runAs: 'system'`; no shipped hook body inserts through `ctx.api`. +- b4b37e5: The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. + + `#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short. + + - **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is. + - **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused. + - **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has. + + To fix a refusal, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one. + + `os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them. +- 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + + `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. + + - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. + + **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. + + **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. + + An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. +- 615fac3: A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. + + `list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all. + + The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this. + + - **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`). + - **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default. + - **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier. + - **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has. + + To fix a dangling reference, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the list. + + `os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them. +- cd55558: `widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts + + `chart-measures-missing` (#15462) reported the authoring placeholder only for the chart + family, but the return that produces it is type-independent. At the `@object-ui` revision + this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683` + reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset + widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and + the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or + `pivot` widget that selects no measures renders the same placeholder — the KPI number or + the table the author declared is not drawn at all — and nothing reported it: + `table-count-only` requires `values.length > 0` before it looks, and the rules that iterate + `dimensions[]`/`values[]` are silent on an empty array by construction. + + - **New id `widget-measures-missing`** — a NON-chart declared widget type selects no + measures. Warning tier, suppressible per widget with + `suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The + message states the consequence its family actually has (the single KPI number is not + drawn / no table is rendered) and the hint names the dataset's declared measures. + - **`chart-measures-missing` is unchanged** — same id, same chart-family population, same + message and same suppression. The condition split rather than widened because "chart" + stops naming it once the population is every family, while the old id is reachable from + the package barrel (a public-surface contract) and may already be written into a board's + `suppressWarnings`. + - `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table` + is what those families are for. + + The two never double-report one widget, in the pin's own order: the measures check runs + before the dimensions one, and `table-count-only` already skips an empty selection. + +### Patch Changes + +- 347b777: `chart-config-missing` no longer fires on a widget whose binding the renderer derives + + The rule warned on every chart-family widget that declared no `chartConfig`, on the + stated grounds that "the renderer cannot determine which measure to plot, so the series + renders empty". Measured against the `@object-ui` revision this repo pins + (`.objectui-sha`), that consequence is false: `DatasetWidget` derives the x-axis key and + one series per measure from the widget's own `dimensions` / `values` via + `buildChartSeries`, and refuses an authored `ChartAxis.field` / `ChartSeries.name` + outright — `chartConfig` carries presentation only. The renderer pins this by name: + "ignores an authored axis `field` and keeps the derived axis binding", "ignores an + authored series and keeps one derived series per measure", "emits none of the + presentation keys when no chartConfig is declared". + + The false finding was landing on this platform's own shipped metadata — the + `system_overview` dashboard's pie and bar tiles, on the Setup board every customer opens + first — which is the ADR-0072 D1 cost the rule family exists to avoid. + + The rule id is unchanged and keeps one true arm: a `combo` widget with no `chartConfig`, + whose per-series mark is authored as `chartConfig.series[].type` and has no other + channel, so every measure draws with the same default mark and the chart is not a + combination at all. Its message now names that consequence instead of the binding. + An existing `suppressWarnings: ['chart-config-missing']` entry stays valid. +- a51eb86: `chart-measure-unknown` no longer blocks a build over a chart `series[].name` (or a page chart's `yAxis[].field`) that names nothing — those positions are presentation, and the message now says so. + + The rule fired at `error` on every measure position of the three chart surfaces it covers, with one consequence sentence: *"result rows are keyed by MEASURE NAME … so this series comes back empty"*. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is true only where the position feeds the dataset query, and the three surfaces do not agree: + + - **Report charts** run the chart's own query out of the two axis strings (`useDatasetRows(dataset, [xAxis], [yAxis], …)` — *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*), so `chart.xAxis`/`chart.yAxis` are the binding. `chart.series[]` is *"the author's per-chart override for ONE measure's display name"*, lowered through `mergeAuthoredSeries`, where *"an authored entry naming a measure that is NOT in the dataset selection is **ignored** — membership belongs to the dataset"*. + - **List-view charts** have no presentation position at all: `ListChartConfigSchema` is a strict object of `chartType`/`dataset`/`dimensions`/`values`, and `values[]` is handed to the chart as the dataset measures. + - **Dataset-bound page chart components** query `{ dimensions, measures: values }` and then replace the authored series wholesale with one derived entry per selected measure, so `properties.series[].name` reaches the renderer not at all and `properties.yAxis[].field` re-points nothing. + + **Behaviour change users see:** the three presentation positions — report `chart.series[].name`, page-component `properties.series[].name` and `properties.yAxis[].field` — drop from `error` to `warning`. A build or a metadata publish that used to be refused because of one of them now succeeds, with the finding on the advisory channel. The finding is KEPT, not deleted: the metadata really is wrong — the author wrote a key and believes it is in force. Every query position (report `chart.yAxis`, and `values[]` on all three surfaces) keeps `error` and its existing message verbatim. + + Two smaller corrections ride along, both from the same read: + + - The page surface's `yAxis[].field` refs are no longer concatenated into the `series[]` limb before the measure walk, so an axis position no longer takes the series message. Reading both shapes on that surface stays deliberate; giving them one sentence was not. + - `chart-axis-not-selected` (a declared measure outside the selection) took the same one-size consequence, *"the query does not return it, so the series plots nothing"*. It keeps that wording at a query position and states the real one at a presentation position, where no series is derived for the name in the first place. + + Note that none of these three surfaces declares `suppressWarnings` — it is a dashboard-widget key — so the new advisories cannot be individually silenced; the hint says so instead of pointing at a key that does not exist. +- 7dafaae: No authoring rule throws on a non-record entry of any stack collection. + + A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape. + + Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before. + + The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked. +- 52b59d6: fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door + + A `null` member of `stack.objects` — what an empty YAML list item + deserialises to, and what a partial editor write leaves behind — crashed + 13 of the 42 `AUTHORING_RULES` with + `TypeError: Cannot read properties of null (reading 'name')`. The + authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the + RAW `lint` path as well as the parsed one, so nothing upstream had judged + the entry's shape. At the runtime publish gate they are called inside the + gate rather than behind a try/catch of their own, so the throw was an + exception on a WRITE path, not a skipped finding; on the CLI, `os lint` / + `os validate` / `os compile` died on the first one instead of reporting + the stack. + + The repair before this one guarded ONE seam — the object-graph index every + field-path rule opens with. The crash stood at fourteen more readers of + the same collection, each a hand-copied `asArray` whose array branch was + an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was + already present in about a dozen siblings and absent in the rest, so + fixing one left the others answering the old way. + + So the copies are gone. `recordsOf` — the guarded reader, exported from + `object-graph.ts` and package-private — is now the one coercion from a + collection authored as an array OR as a name-keyed map into the records it + holds, and fifteen files call it: + + - `validate-expressions.ts`, `validate-list-view-mode.ts`, + `validate-widget-bindings.ts`, `filter-walk.ts`, + `validate-object-references.ts`, `validate-record-title.ts`, + `validate-form-layout.ts`, `lint-autonumber-formats.ts`, + `lint-view-refs.ts`, `validate-org-axis-red-lines.ts`, + `validate-sharing-rule-enforceability.ts` — the eleven sites that threw. + - `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and + `validate-page-field-bindings.ts`'s `indexObjectFields` — two shared + indexers inside the reference-integrity suite, each in front of two + rules and both hidden behind whichever suite member threw first. + - `object-field-groups.ts`'s `indexObjectFieldGroups`, which the + re-measure surfaced only once the eleven above stopped throwing. + - `validate-security-posture.ts`, the one that never threw: an `[]` + member passed its `typeof v === 'object'` read and drew a second + `security-owd-unset` at `object "(object 0)"` — an `error` about an + entry no author wrote. + + The verdict is a SKIP, not a finding, matching the seam it extends: a junk + `objects` member is a SHAPE defect and belongs to the schema, every rule + already re-answers the question in its own per-object guard, and reporting + it at the reader would emit one finding per member for one bad entry. On + the name-keyed map shape a member whose VALUE is unreadable keeps its key + (`{ name }`) — the author named it, only its body is illegible. + + No rule tier, id, message or accept-set changes. A valid object standing + beside a junk one is judged exactly as it is judged alone; only a path + index moves, and only for the rules that index `objects` raw, where + `objects[1]` is the honest position. +- 25a3d91: Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing" + + The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations` + finding from a `type`-only test. The declarative single-record field write + (`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes + for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps + `type` at its default `script`, because the platform action route is where the + write is performed. Every such action was named at every boot and every + `metadata:reloaded` — with a prescription ("add a `body`, or register a handler + under the declared `target`") that parse itself refuses. + + Both readers now read `operation` before `type`, the precedence the runtime doors + already use: the engine inventory, and the authoring-time AI tool-reference rule, + which had diverged from the runtime's listing door and reported a resolvable + `action_` reference as fictional. +- ba426b0: A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong". + + `indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before. + + On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify. +- 89758ac: `chart-axis-not-selected` resolves a report chart against its own `chart.yAxis`, not `report.values` (#15734) + + **Behaviour change — one false finding removed on the report surface.** A report chart whose `chart.yAxis` names a declared measure that `report.values` does not select no longer raises a `chart-axis-not-selected` warning. Nothing else about the rule moves, and no other surface moves at all. + + The warning stated a query consequence the renderer refutes. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), `plugin-report/src/DatasetReportRenderer.tsx` does not query `report.values` for the chart at all — it runs the chart's own, narrower query out of the two axis strings: + + ``` + const state = useDatasetRows( + dataset, + plan.kind === 'series' && xAxis ? [xAxis] : [], + wantsQuery && yAxis ? [yAxis] : [], + ``` + + and says so in that file's own words at the `scopeOrder` docblock: *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. So the measure the warning said "the query does not return" is exactly the one the query asks for, and the chart plots it. `report.values` is the selection of the TABLE beneath the chart. + + Both limbs follow from that one measurement: + + - **No not-selected check at the report `chart.yAxis`.** That position IS the chart's query, so it cannot fail to select itself. `chart-measure-unknown` there is untouched: an UNDECLARED measure is still no column at all, and still an `error`. + - **`chart.series[].name` resolves against the singleton `{ chart.yAxis }`.** The entry is a display-name override paired with a DERIVED series, and the chart derives exactly one (`buildChartSeries(…, [xAxis], [yAxis], …)`). An entry naming `chart.yAxis` now lands however the table is selected, and one naming any other declared measure is still reported — including a measure `report.values` does select, which it could not reach before. + + The list-view and page-component surfaces are unchanged, and carry firing controls that say so: on both, `values` IS the measure set the query asks for (`ObjectView` hands it to the chart; `ObjectChart` queries `{ dimensions: schema.dimensions, measures: schema.values }`), so the existing resolution is the right one there. + + The per-position tier and consequence wording is untouched — only the SET the report surface resolves against moves. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/sdui-parser@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 8d3e50e83a..5ebd2c873b 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 52b443d228..a0910442ec 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,99 @@ # @objectstack/plugin-mcp-server +## 17.4.0 + +### Patch Changes + +- 17f8604: The MCP stdio transport now vets an API key's organization against the deployment's tenancy posture, instead of trusting the key's own stored claim. + + `resolveStdioExecutionContext` — the whole of this transport's authorization, since every caller on it is an API key by construction and there is no session path — built its own header map and called `resolveAuthzContext` with no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one (`organization_required` at admission, `organization_membership_ended` after grants), so a door that supplied none ran neither: the key's `sys_api_key.active_organization_id`, never re-checked against current membership, became the request's tenant. Under a wall-enforcing posture a key stamped with an organization its owner had left read and wrote that organization's rows through this door. + + The posture is now derived in the plugin's `start()`, where the kernel is reachable, and threaded into the resolver. What changes for a deployment: + + - Under `isolated` or `group`, a stdio transport configured with a key whose owner is no longer a member of the organization the key names refuses to start, and a key already live is refused on its next call. Under `isolated`, an organization-less key is refused the same way. Both refusals are logged server-side naming the key, principal, organization and reason; nothing about them reaches the caller. + - A kernel that registers no `tenancy` service is unaffected: no organization wall exists there, so no posture-conditional refusal is made. That is the supported composition, not a degraded one. + - A `tenancy` service that is registered and **fails to build** now raises `SERVICE_UNAVAILABLE` (503) rather than reading as "no posture". A posture that could not be read is not a posture that is absent, and admitting on one is the permissive-on-failure shape this repair exists to avoid. + + The posture is re-read per call, on the same schedule as the identity beside it (ADR-0101 D1), so a wall that comes up or a membership that ends mid-session takes effect on the next call rather than at the next restart. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e0823a503d..cefcb02b24 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)", "type": "module", diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md index 62d731785b..b7ff2a9f1e 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,76 @@ # @objectstack/metadata-core +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index bb81c19da6..c669e6d0c1 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).", "type": "module", diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md index 5e51f281c0..5e1b66759f 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,11 @@ # @objectstack/metadata-fs +## 17.4.0 + +### Patch Changes + +- @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 3c28f016cb..bda6ba4bb5 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).", "type": "module", diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md index 74d3a950ed..cf3a3f4fe6 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,312 @@ # @objectstack/metadata-protocol +## 17.4.0 + +### Minor Changes + +- 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + + A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. + + Two changes, and neither moves what a rule evaluates to: + + - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. + - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. + + `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. +- 65846bc: fix(metadata-protocol)!: a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723) + + + + **BREAKING** on the per-row report of `POST /api/v1/data/:object/batch` (and + the multi-object `POST /api/v1/batch`, which rides the same protocol): a row + refused by the engine's `DuplicateRecordError` envelope now reports + `errors[].code: 'UNIQUE_VIOLATION'` where it reported `'DUPLICATE_RECORD'`. + Shipped as `minor` under the repo's launch-window convention for breaking + changes. Maintainer ruling 2026-09-03 on #14723 (verbatim 「同意,然后执行契约 + 复审」), adopting option A: one wire spelling for a unique-constraint refusal on + every route. + + **Why.** `toRowApiError` put a thrown REGISTERED code on the row verbatim, and + `DUPLICATE_RECORD` is registered, so a `DuplicateRecordError` row said + `DUPLICATE_RECORD` while the whole-request failure on the very same route (the + bulk door's classification in `@objectstack/rest`) answered `UNIQUE_VIOLATION` + — the standard-catalog member `content/docs/protocol/kernel/http-protocol.mdx` + documents for the 409 constraint-violation body. Since the bulk doors were + restored to `UNIQUE_VIOLATION`, the two spellings of one condition sat side by + side in one route's responses, which ADR-0112's one-name-per-concept and the + error-code ledger's own header both forbid. The duplication is removed, not + declared: no ledger waiver is added. + + **What changes.** The row derivation recognises the engine's envelope by the + same two-part gate the whole-request arm uses — the registered code AND the + class name `DuplicateRecordError`, never message text — and reports + `UNIQUE_VIOLATION`. Everything else on the row is unchanged: `httpStatus: 409`, + the platform sentence (no driver text, no bound value — the driver's error + stays on `cause` and never reaches the row), and the sibling `NOT_ATTEMPTED` / + `ROLLED_BACK` rows. + + **What does NOT change.** The engine's thrown identity: `DuplicateRecordError.code` + is still `DUPLICATE_RECORD` for an in-process caller of `engine.insert` / + `engine.update` (a hook, a flow node), and the objectql pins on `insert` / + `insertMany` hold. The single-record `/data` door, which has answered + `UNIQUE_VIOLATION` throughout, does not move. A producer that merely THROWS the + registered `DUPLICATE_RECORD` from its own body without being the engine's + class keeps its own code on the row, exactly as it does at the door. + + **Consumer note.** A batch client that branched on a row's `code` reading + `DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it + already handles for the whole-request 409 on that route and on the + single-record door. Measured in-repo and in the sibling repos (hotcrm, objectui, + non-test sources): zero consumers branch on either spelling of a row code. +- 6491463: `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + + **A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + + **The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + + **Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + + What to read instead, per case: + + - deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; + - asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + + Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + + New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. +- b4b37e5: The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. + + `#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short. + + - **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is. + - **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused. + - **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has. + + To fix a refusal, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one. + + `os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them. +- 615fac3: A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. + + `list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all. + + The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this. + + - **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`). + - **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default. + - **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier. + - **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has. + + To fix a dangling reference, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the list. + + `os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + + +### Patch Changes + +- e1d4f9e: `getMetaItemLayered` no longer reports a phantom org-scoped row as a tenant customization. + + `getMetaItemLayered` is the three-layer diagnostic behind Studio's "Code default vs Overlay vs Effective" view, and the third `/meta` read verb in the series `getMetaItems` (plural) and `getMetaItem` (singular) were repaired in. Unlike those two it applied no registry read gate of its own: whatever organization a caller passed was spent on whatever type it passed. On a type the registry declares `allowOrgOverride: false` — everything outside the ADR-0005 tier-A five (`view`, `dashboard`, `report`, `translation`, `email_template`) — a deployment with history can hold pre-#6190 phantom org-scoped rows, which boot hydration deliberately walks past. Read back through this verb they surfaced as `overlay` with `overlayScope: 'org'`: an operator was shown a customization that does not exist, in the one surface built to be authoritative about customizations. + + It was not only displayed. Two doors return that layer **as the response** when it is non-null — the runtime metadata dispatcher and REST `GET /meta/:type/:name/published` — so on those paths the phantom was served as the item. + + The read now resolves its organization through `organizationIdForMetaRead`, the same registry-derived predicate the REST `/meta` doors have applied since #9454 and the twin of the write side's `organizationIdForMetaWrite`. A type with a per-org read channel still resolves the caller's organization and still reports `overlayScope: 'org'`; every other type reads env-wide, which is the partition that actually runs. + + **The gate is bound after the canonical type fold, and that ordering is load-bearing.** In the two sibling verbs the binding already sat below `canonicalizeMetaRequestType`, so the fix there was a substitution. Here it sat above it, and dropping the same expression in place would have gated on the raw `/meta/:type` segment: `declaresOrgOverride` tolerates the manifest plurals but not the URL-only spellings (`translations` and `email_templates` have no manifest key), so a raw segment splits one item across two partitions, addressed by spelling. The repair is therefore a reorder, and it is pinned by a test that fails if the binding moves back above the fold. + + Callers that name no organization — four of the five `plugin-security` invocations, and every import/analytics/auth reader — are unaffected, and a door that already computed the same predicate receives the scope it did before. +- ba426b0: A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong". + + `indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before. + + On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify. +- 618f70d: A dashboard bound to a dataset you just saved now publishes, without restarting the runtime. + + The author-time gate that runs on every `active` metadata publish resolves a widget's `dataset` (and a `type: 'page'` view's `pageName`, and the sibling collections the cross-collection security rules compare against) against a resolution universe the host gathers per write. That gather read the SchemaRegistry alone. The registry is filled at boot by code packages, and for every metadata type except `object` a runtime write does not reach it — so a dataset saved through `PUT /api/v1/meta/dataset` was invisible to the gate until the process restarted, while `GET /api/v1/meta/dataset` returned it in the same instant with `_diagnostics.valid: true`. + + Measured on the reported shape, in one process with no restart between the steps: the row is in `sys_metadata`, the read API lists six datasets, the registry lists the five code-package ones, and a three-widget board bound to the new dataset was refused `422` with three `widget-dataset-unknown` issues whose hint enumerated every dataset except the one just authored. The same request answered `200` after a restart, nothing else changed. + + The gather now folds the stored half onto the registry half for every collection it carries. What that does and does not do: + + - **Additive.** A stored row contributes a name the registry does not already carry and never displaces a registry entry — an object's registry copy is its resolved schema (base plus `extend` contributors) and a raw `sys_metadata` row is the base layer alone, so replacing it would trade this phantom for a subtler one. Where an org overlay redefines a code-package item, the gate still judges that item's content from the registry's version. + - **Active rows only.** A draft does not resolve. The refuse-at-publish ruling exists so an author can write the widget first and the dataset second; a draft dataset that satisfied a published board would invert it. + - **Scoped to the write's own partition** — environment-wide rows plus, when the write has one, its own organization. No other organization's overlays are visible to the gate, on any kernel. + - **A failed store read is reported, not swallowed.** Context gathering still never fails a write, but a read that fails for any reason other than an unprovisioned `sys_metadata` now says so once, naming the consequence — a gather that silently shrinks is how a phantom refusal is manufactured in the first place. + + The rules themselves are unchanged: a reference that resolves in neither home is still refused, with the same code, status and key path. +- 4b0508e: docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) + + Comment and prose only. No predicate, no assertion and no served shape changes; + every pin behind the `writable` verdict stays green as written. + + The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own + explanation, and this corrects it at every site that repeated it. The claim was + that the scope-less booted row `isWritablePackage` answers `false` for is *the + `type: module` sub-package a multi-package artifact carries*. It is not, and it + never was: + + - `defineStack` parses every `packages[]` entry through `ManifestSchema` + (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is + `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of + a compiled artifact is ever scope-less — `dist/objectstack.json` and both + served rows carry `scope: "project"`. + - A genuinely scope-less row arises only where a manifest reaches the registry + **without** that parse, because `installPackage` stores a key-by-key copy that + applies no defaults: a marketplace install / offline file import + (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, + read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to + `installPackage`) for the **database base, writable** half. + + Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an + unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` + of the same unparsed body yields a record with no `scope` key at all. + + What stays, because it is true and load-bearing: a scope-less **booted** package + is read-only while a scope-less **database base** is writable, and only + `engine.manifests` tells them apart — which is why the server owns the verdict. +- 7d711c9: `findReferencesToMeta`'s unanswerable-target refusal now opens with prose instead of a machine-shaped `[unanswerable_target]` tag that nothing read. + + ``` + before 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} + after 501 {"error":{"code":"NOT_IMPLEMENTED","message":"References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} + ``` + + Nothing else moves: same `501`, same `NOT_IMPLEMENTED`, same envelope position, and the prescriptive sentence ADR-0110 D3 requires is untouched. Callers branch on `code`, which is unchanged; only the human-facing sentence is shorter. + + Why the tag was wrong here specifically. This producer writes a bracketed tag on many refusals, and every other one is the lowercase restatement of that throw's own declared `code` — `[item_locked]` with `ITEM_LOCKED`, `[no_draft]` with `NO_DRAFT`, `[invalid_request]` with `INVALID_REQUEST`. Measured across the two producer files, 30 of the 31 tagged throw sites that declare a code restate it that way. This refusal declares `NOT_IMPLEMENTED`, so its tag was the sole exception: it named a token the envelope carries on no axis, and a repo-wide search finds no parser, no switch, no assertion and no doc that reads it. Per the ruling behind the `/data` door's `FORBIDDEN:` prefix removal, `error` is human language and `code` is the machine token. + + It became worth fixing when the `/meta/:type/:name/references` door started relaying the producer's prose verbatim: before that the whole sentence was replaced by `Internal server error` and the tag reached nobody, and after it the tag was the first thing an operator read on the screen where they decide whether to delete something. The `@objectstack/rest` entry in this release quotes the pre-removal sentence in its example; this entry is the later word on that wire text. + + The absence is now pinned in `protocol.reference-target-unanswerable.test.ts` — nothing pinned the tag, so without a pin nothing would have pinned its removal either. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [954cb0b] +- Updated dependencies [a56baa2] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [347b777] +- Updated dependencies [36a16d0] +- Updated dependencies [c01b3a6] +- Updated dependencies [a51eb86] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [7dafaae] +- Updated dependencies [52b59d6] +- Updated dependencies [720bf47] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [efc5447] +- Updated dependencies [89758ac] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [cd55558] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/lint@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index cd18ee5fa9..1839dc65c7 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).", "type": "module", diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md index 61eb06aedd..502f6558e2 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,328 @@ # @objectstack/metadata +## 17.4.0 + +### Minor Changes + +- a56baa2: feat(metadata,objectql): a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router + + Two plural reads of one metadata plane could disagree with a by-name read of + that same plane, and the ADR-0110 D5 action-governance audit stood on the + disagreement — reporting `registered handler with NO declaration … REFUSED at + dispatch` about a route the router was resolving and dispatching in the same + boot. + + **`MetadataManager.listNames` gains the per-loader `try`/`catch` that + `loadMany` and `list()` have carried since #5108.** One loader fault used to + produce two different facts depending only on which plural read a caller + reached for: `loadMany` swallowed it and answered short, `listNames` threw. It + now degrades the same way, through the same `reportLoaderReadFailure` / + `reportLoaderReadRecovered` helpers — one outage, one line, one vocabulary. + Callers that relied on `listNames` throwing to detect an outage should read + `listDiagnosed()`, which reports `degraded` explicitly. + + **New: `MetadataManager.loadManyKeyed(type, options?)`** — `loadMany` read under + the identity the STORE holds each item by, returning `{ name, data }` pairs. It + delegates to a loader's own `loadManyKeyed` where one is offered (on + `DatabaseLoader` that shares `loadMany`'s single query, so it costs nothing + extra) and otherwise falls back to that loader's `list()` + per-name `load()`. + ⛔ **`loadMany`'s published return shape does not change**, and no existing + consumer is touched: the key travels *beside* the body, never inside it, so a + body that deliberately carries no `name` stays byte-identical to what was + stored (#14205). + + **The action-governance audit now mirrors the router on both halves of the D5 + bijection.** The declaration half enumerates the plane keyed + (`loadStandaloneActionsKeyed`), so a row whose body does not name itself — a + `sys_metadata` row keyed by its `name` column, or a `FilesystemLoader` file + whose identity is its path — is a declaration to the audit exactly as it is to + the router; the handler half also probes the plane BY NAME + (`lookupMetadataAction`, `loadDiagnosed`/`load`, injected like the existing + registry rung), so a loader fault a plural read swallows can no longer turn a + dispatchable handler into an accusation. Both probes stay conservative in one + direction only: a source that throws leaves the handler on the list. + + Additive on every published signature. `runActionGovernanceInventory` and + `collectEngineActionDeclarations` gain optional parameters and keep their old + ones working unchanged; declaration rows gain an optional `storeKey` (the new + exported `ActionDeclarationRow`). + + **Population change, reported:** `unboundDeclarations` now sees declarations + whose identity is the store key. Its BEFORE was **0, structurally rather than + by sampling** — a nameless row was dropped before reconciliation ran, so it + could never be reported however many a plane held. Its one deliberate + subtraction: a row with neither an own `name` nor a store key is no longer + reported as `actionName: undefined`, which read as a parse failure in the + warning rather than as a finding. + + Known boundary, stated in the audit's docblock rather than left to be + rediscovered: a boot-time audit runs outside any request scope, so if a + composition ever registered `metadata` as `SCOPED` the audit could not reach + that instance at all — before any read method runs. No shipped composition does + (`packages/metadata/src/plugin.ts` registers a static instance), and reaching a + request-scoped service from a boot-time audit is a separate change. +- c1d274d: fix(metadata): two files sharing one stem are refused with both paths named, instead of one being listed twice and served by extension precedence (#14921) + + **BREAKING** accept-set narrowing on `FilesystemLoader`, shipped as `minor` + under the repo's launch-window convention for breaking changes. Ruled on + #14921 (2026-09-05, option 1 of three). + + **Remedy: delete or rename the duplicate file.** The refusal names every + colliding path and the metadata type, so the fix is visible at the point of + failure. + + `FilesystemLoader` derives a metadata name by stripping a flat file's + extension, and resolves a name back to a file under a FIXED extension + precedence (`.json` → `.yaml` → `.yml` → `.ts` → `.js`). Two files sharing a + stem therefore produced one name **twice** in `list()` while only the + first-precedence file was reachable through any name at all. With + `object/twin.json` and `object/twin.yaml` both present, `list()` answered + `['twin', 'twin']`, `twin.yaml` was addressable through nothing, and + `loadMany()` returned both bodies. `MetadataManager.listNames()` unions loader + output into a `Set`, which collapsed the duplicate and took the count + discrepancy with it — the file stayed unreachable either way, so a clean + `listNames()` was never evidence the collision had been absorbed. + + The invariant that broke: **what is listed is what is loadable.** The listed + set and the addressable set stopped being the same set. The failure was silent + in the direction that matters for authoring — convert `twin.json` to + `twin.yaml` and leave the old file behind, or land one from each of two + packages, and the JSON one is served forever with no diagnostic anywhere, + while `admitLoaderItems()`'s documented "keep the first and say nothing" + absorbs the collision a second time. + + `FilesystemLoader.list()` now throws `AmbiguousMetadataStemError` + (`AMBIGUOUS_METADATA_STEM`, HTTP 500) naming both paths and the type, and the + same refusal fronts the shared `loadMany()` / `loadManyKeyed()` walk, so the + two-body answer is gone rather than de-duplicated. `MetadataManager.listNames()` + and `list()` **propagate** it rather than absorbing it into their per-loader + degradation: an ambiguous stem is an authoring error no retry fixes, and + degrading it would drop every item the loader holds into a short-but-served + list while the server keeps reporting healthy. A real storage outage still + degrades exactly as before — the seams discriminate on a branded predicate, + `isAmbiguousMetadataStemError`, not on a blanket rethrow. + + **Refused shape**, precisely: two or more files **directly under + `ROOT/TYPE/`** whose basenames differ only by an extension belonging to one of + **this instance's registered serializers**. Register `javascript` and + `dual.json` + `dual.js` becomes ambiguous; under the manager's default format + set (`typescript` / `json` / `yaml`) it is not, because `.js` derives no name. + Nested files are untouched — they are neither listed nor resolvable (#14486), + so `crm/solo.json` beside a flat `solo.json` is not a collision. The refusal is + scoped to the type directory that holds it: a clean `view/` still lists while + `object/` refuses. + + New exports from the package root entry: `AmbiguousMetadataStemError`, + `isAmbiguousMetadataStemError`, `AMBIGUOUS_METADATA_STEM_CODE`, + `AMBIGUOUS_METADATA_STEM_STATUS`. + + Measured migration cost, which is what makes this narrowing cheap: **no tree in + this repository carries the shape.** A walk of all 7,770 tracked files across + 526 directories found zero stem collisions among `.json` / `.yaml` / `.yml` / + `.ts` / `.js`, confirmed independently by a `git ls-files` pass, and the repo + holds no `.yaml`/`.yml` metadata file at all outside CI and workspace config. + No existing tree goes red. + + +- e9fcd6b: feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478) + + + + **BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape + (`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the + launch-window convention. `MetadataManager` hands `config.cache.databaseLoader` + straight to `new DatabaseLoader({ cache })`, so this option is the spec key + `cache.databaseLoader.ttlMs` one layer down and renames with it: a loader + configured with `ttlMs: 60_000` expires entries after 60 seconds exactly as + `ttl: 60_000` did. The README example and the kernel metadata-service docs page + spell the new key. + + ```ts + // before + new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttl: 60_000 } }); + // after + new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttlMs: 60_000 } }); + ``` +- 3bd9b34: feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function + + `packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through + `@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — + re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure + inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> + `chokidar`, plus `glob`, `js-yaml` and `readdirp`. + + The same file already carried the answer 79 lines above, at its + `@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a + cross-package consumer gets the predicate without the manager, the loaders or + the YAML/filesystem machinery behind the root entry". This is that pattern, + taken a second time. + + **Measured on the built artifacts, not asserted** — every module Node actually + evaluates when `@objectstack/objectql/core` is loaded in a fresh process, + recorded through a `module.registerHooks` load hook (ESM and CJS) plus + `require.cache`, byte sizes from `statSync`: + + | `@objectstack/objectql/core` | modules | bytes | + |:---|---:|---:| + | before (ESM `dist/core.mjs`) | 190 | 12,348,424 | + | after (ESM `dist/core.mjs`) | 185 | 11,849,808 | + | **delta** | **-5** | **-498,616 (-486.9 KiB)** | + | before (CJS `dist/core.js`) | 188 | 12,654,238 | + | after (CJS `dist/core.js`) | 183 | 12,141,034 | + | **delta** | **-5** | **-513,204 (-501.2 KiB)** | + + Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), + `js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and + `readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal + module-init time for that root entry, measured on a warm lean closure, was + ~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. + + ⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about + the delta and wrong about the baseline: the lean entry's closure was already + ~11.5 MiB before this import existed, dominated by `@objectstack/spec` + (9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry + contributes. What the root import cost was ~487 KiB *on top of* that, not a + closure of 450 KB. + + The derivation itself moves to `packages/metadata/src/view-container.ts`, a + module with **no imports at all**, and `view-container-expansion.ts` imports + and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their + spelling and the symbol stays on the root entry — this subpath is an additional + door, not a relocation. A re-export shim onto `view-container-expansion.ts` was + tried first and rejected on measurement: esbuild tree-shakes the unused + `expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import + statements, so that shim's own closure was 84 modules / 3,035 KiB. The real + leaf's is 1 module / 469 B. + + `expandRuntimeViewContainer` is deliberately not exported from the new subpath: + `metadata-manager.ts` is its only caller, the root entry does not export it + either, and it is the half that carries the spec machinery. +- 8647c87: A completed run of the ADR-0030 notification cut-over now records itself in the `sys_migration` deployment ledger, per the ruled claim matrix. + + `migrateSysNotificationToEvent` reports `migrated` / `already_done` / `not_applicable` / `error` to its caller and — until now — recorded nothing anywhere. Once that line had scrolled, "did this cut-over run here, and when" had no answer in the deployment even in principle. The ledger row is what answers it, and what a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` is stated on that constant in `@objectstack/spec/system`: + + - `last_run_at` — stamped on every completed non-`error` run (`migrated`, `already_done`, `not_applicable` alike). + - `applied_at` — stamped only on `migrated`. Never cleared: a later `already_done` leaves an earlier backfill's stamp alone, because the backfill really did happen. + - `verified_at` — never written, in either direction. This migration has no self-check, and `verified_at` means a self-check passed. On a store created after the cut-over the row already exists and `attestFreshDatastore` set `verified_at` at birth; that certificate survives a run untouched, because the column is omitted from the update rather than sent as `null`. + - `blocking: 0`, and `details` carrying `{ outcome }` verbatim. + - An `error` run writes no claim at all — it does not know what it did, so it does not say. + + **Receipt, not gate.** Nothing reads a row under this id as a precondition and nothing may: a gate would need the self-check that does not exist. The row is what an operator reads, in the shape `sys_migration` already documents for the seed-tenancy repair. + + Two additions to the published surface of `@objectstack/metadata/migrations`, both driven by that: a new `SysNotificationMigrationReceipt` type, and a new `receipt` member on `SysNotificationMigrationResult` reporting what became of the claim (`inserted` / `updated` / `not-claimed` / `no-ledger` / `failed`, with a reason on the last two). This directory takes no logger and reports to its caller, so the claim's own fate is reported the same way the migration's is — a receipt that could not be written is never swallowed. Reading a result is unaffected; code that CONSTRUCTS a `SysNotificationMigrationResult` by hand (a test double) now supplies `receipt`. + +### Patch Changes + +- 0c5d035: fix(metadata): a `HistoryCleanupManager` run that loses deletes now says so, at both of `start()`'s triggers + + A failing history cleanup was completely silent. Three things composed: every inner `catch` on the delete path is a bare `catch {`, so the error object is discarded; the only `console.error` in `runCleanup()` sits in its OUTER catch, which those inner catches prevent execution from reaching; and `start()` invoked the run as `void this.runCleanup()`, throwing away the `{ deleted, errors }` the run returns — at BOTH call sites, the immediate run and every interval tick. A driver whose deletes failed on every scheduled run therefore produced zero output and no reachable error count, while the history table grew past its retention policy with nothing to find. + + The repair reads the envelope instead of replacing it. `runCleanup()`'s contract, its inner catches and its counting are unchanged: reporting a failure to the CALLER is the third answer AGENTS.md → "Degradation log levels" allows a durability seam, and that same section names a log per failed write as the mirror-image failure. What was missing was a reader — `start()` is where the chain ends, since it returns `void` and an interval tick has no caller at all. Both call sites now go through one shared pass that reads the returned counts and, when a run lost deletes, prints one `error` line naming the consequence (rows past the retention policy are still in the table, nothing retries them, and the system keeps reporting healthy) and where to look. A run that loses nothing stays quiet, and a direct caller of `runCleanup()` sees exactly the same `{ deleted, errors }` as before. +- 281bf0d: `HistoryCleanupManager` computes its retention cutoff on one calendar, not two. + + Both call sites — the age-based delete in `runCleanup()` and the preview count in `getCleanupStats()` — built the cutoff with `cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays)` and then rendered it with `toISOString()`. `setDate`/`getDate` read and write the **local** calendar; `toISOString()` renders **UTC**. They now use `setUTCDate`/`getUTCDate`, so the arithmetic and the rendering agree. + + `setDate` preserves wall-clock time, so shifting the local calendar back `n` days moves the *instant* by exactly `n × 24h` only while every local day in the window is 24 hours long. When the window straddles a DST transition it is 23 hours (spring-forward) or 25 (fall-back), and the cutoff instant that goes into the `recorded_at: { $lt: … }` **delete** filter is off by the size of that transition — one hour in most zones, thirty minutes on Lord Howe Island. History rows within that slip of the retention boundary were deleted early, or retained too long. + + The exposure is not limited to the two transition days: the window only has to *straddle* a transition, so it grows with `maxAgeDays`. Measured over a 12-zone × 366-day × 48-half-hour sweep of 2026, in `America/New_York` the old spelling produced a wrong cutoff for 0.6% of instants at `maxAgeDays: 1`, 16.4% at 30, 49.7% at 90 and 69.4% at 180. In zones that do not observe DST (`UTC`, `Asia/Shanghai`, `Asia/Kolkata`, `Australia/Perth`) the rate is 0.0% at every `maxAgeDays` — which is why no test had ever gone red on this. + + This does **not** make retention timezone-aware, and does not change what `maxAgeDays` means. The cutoff was already intended to be `now − maxAgeDays × 24h`; it is now that in every zone rather than only in zones without DST. Nothing else in either filter moved: the `organization_id` scoping, the ADR-0009 `executionPinned` exclusion and the `maxVersions` path are untouched. +- efc5447: `RemoteLoader.list()` no longer reports a nameless remote body as a literal `undefined`. + + The method declares `Promise` and read the collection as `loadMany<{ name: string }>(type)` before mapping `items.map(i => i.name)`. That type argument is an **assertion** about bodies that arrived over HTTP, and nothing checked it: a body with no top-level `name` yielded `undefined`, which went into an array the signature declares as `string[]`. `MetadataManager.listNames()` unions loader `list()` output unfiltered, so the violation reached consumers — measured on this fixture, `listNames()` answered `[ 'account', undefined, 42 ]`. + + The guard is `DatabaseLoader.list()`'s, one file away: the same cast-then-map spelling with `.filter(name => typeof name === 'string')` behind it. `RemoteLoader` was the only one of the four loaders in that directory with no guard at all — `MemoryLoader` answers with its store keys, and `FilesystemLoader` reports only names `findFile()` resolves. Dropping silently rather than throwing is the direction those siblings already carry: a name in the list that the door answers `null` for is the silent failure an author reads as their own typo, so the list is narrowed to agree with the door. + + Nothing that was validly returned before stops being returned: the only entries that disappear are the ones whose type the signature already ruled out. A caller that previously received `[undefined]` now receives `[]`. `loadMany()` is deliberately untouched — it keys nothing, so a body carrying no `name` is still served there; this loader reads over HTTP and holds no store key, so `body.name` is the only identity it has and the family's "identity is the store key" rule cannot be satisfied for it. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/metadata-fs@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 7bdfc79e7a..2fbbe01d0e 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Metadata loading, saving, and persistence for ObjectStack", "type": "module", diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index 96d90aa757..9041dfd8c2 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,573 @@ # @objectstack/objectql +## 17.4.0 + +### Minor Changes + +- 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + + A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. + + Two changes, and neither moves what a rule evaluates to: + + - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. + - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. + + `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. +- a56baa2: feat(metadata,objectql): a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router + + Two plural reads of one metadata plane could disagree with a by-name read of + that same plane, and the ADR-0110 D5 action-governance audit stood on the + disagreement — reporting `registered handler with NO declaration … REFUSED at + dispatch` about a route the router was resolving and dispatching in the same + boot. + + **`MetadataManager.listNames` gains the per-loader `try`/`catch` that + `loadMany` and `list()` have carried since #5108.** One loader fault used to + produce two different facts depending only on which plural read a caller + reached for: `loadMany` swallowed it and answered short, `listNames` threw. It + now degrades the same way, through the same `reportLoaderReadFailure` / + `reportLoaderReadRecovered` helpers — one outage, one line, one vocabulary. + Callers that relied on `listNames` throwing to detect an outage should read + `listDiagnosed()`, which reports `degraded` explicitly. + + **New: `MetadataManager.loadManyKeyed(type, options?)`** — `loadMany` read under + the identity the STORE holds each item by, returning `{ name, data }` pairs. It + delegates to a loader's own `loadManyKeyed` where one is offered (on + `DatabaseLoader` that shares `loadMany`'s single query, so it costs nothing + extra) and otherwise falls back to that loader's `list()` + per-name `load()`. + ⛔ **`loadMany`'s published return shape does not change**, and no existing + consumer is touched: the key travels *beside* the body, never inside it, so a + body that deliberately carries no `name` stays byte-identical to what was + stored (#14205). + + **The action-governance audit now mirrors the router on both halves of the D5 + bijection.** The declaration half enumerates the plane keyed + (`loadStandaloneActionsKeyed`), so a row whose body does not name itself — a + `sys_metadata` row keyed by its `name` column, or a `FilesystemLoader` file + whose identity is its path — is a declaration to the audit exactly as it is to + the router; the handler half also probes the plane BY NAME + (`lookupMetadataAction`, `loadDiagnosed`/`load`, injected like the existing + registry rung), so a loader fault a plural read swallows can no longer turn a + dispatchable handler into an accusation. Both probes stay conservative in one + direction only: a source that throws leaves the handler on the list. + + Additive on every published signature. `runActionGovernanceInventory` and + `collectEngineActionDeclarations` gain optional parameters and keep their old + ones working unchanged; declaration rows gain an optional `storeKey` (the new + exported `ActionDeclarationRow`). + + **Population change, reported:** `unboundDeclarations` now sees declarations + whose identity is the store key. Its BEFORE was **0, structurally rather than + by sampling** — a nameless row was dropped before reconciliation ran, so it + could never be reported however many a plane held. Its one deliberate + subtraction: a row with neither an own `name` nor a store key is no longer + reported as `actionName: undefined`, which read as a parse failure in the + warning rather than as a finding. + + Known boundary, stated in the audit's docblock rather than left to be + rediscovered: a boot-time audit runs outside any request scope, so if a + composition ever registered `metadata` as `SCOPED` the audit could not reach + that instance at all — before any read method runs. No shipped composition does + (`packages/metadata/src/plugin.ts` registers a static instance), and reaching a + request-scoped service from a boot-time audit is a separate change. +- 33388f9: Engine refusals now declare their HTTP status under both spellings: `httpStatus` beside the existing `status`, same number, at every producer in the package. + + `status` is unchanged and stays. It is what every HTTP door in this repo reads — `resolveThrownHttpError` (`@objectstack/types`) resolves `.status` then `.statusCode` and knows no other spelling — so nothing about what the REST or dispatcher doors answer changes. + + What changes is what a consumer holding the **thrown** error can read. ADR-0112 D5 records the destination as "the HTTP status lives on the transport and (optionally) `error.httpStatus`", and `httpStatus` is the key the client SDK already stamps on every wire failure. A consumer that caught an engine refusal locally had no status at all: `os migrate summary-nulls --json --recompute-undefined-on-empty customer.nope` emitted `{ error, code: 'INVALID_FIELD' }` with no status field, while the same refusal arriving over the wire carried `httpStatus: 400`. It now carries `httpStatus: 400` on both paths. + + Additive on thrown errors, so no caller that reads `status` needs to change. The 20 producers: the `INVALID_SORT` / `INVALID_FIELD` / `VALIDATION_ERROR` / `INVALID_METADATA` / `DELETE_RESTRICTED` refusals in `engine.ts`, the `INVALID_FILTER` / `INVALID_FIELD` refusals in `filter-comparand-shape.ts`, `resolveRecomputeScope` in `summary-backfill.ts`, and the eight error classes declaring a `readonly status` (`DuplicateRecordError`, `HookUnscopedDataAccessError`, `MultiUpdateHookKeyDivergenceError`, `EmptyCredentialWriteError`, `SystemWriteOrganizationRequiredError`, `NamespaceConflictError`, `ArtifactObjectNameConflictError`, `ObjectOwnershipConflictError`). +- fa125f3: feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) + + **BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + The key is **already published, and published unenforced**. The version-packages + cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset + `field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which + declares `Field.valueDomain`, parses it, and refuses it on any type other than + `text` — and never reads it when a record is written. The 17.3.0 liveness ledger + states the gap in its own words: "a non-member WRITTEN to a `text` field + declaring a domain is accepted today". That write is accepted on 17.3.0 and is + refused from this release on. + + **Refused shape**, precisely: a record write that supplies a value for a `text` + field whose definition declares `valueDomain`, where the WRITTEN value is not a + member of the named standard. It fails with the field error code `value_domain`, + carrying `constraint: { valueDomain }` and a message that names the standard in + all four platform locales. Nothing else narrows — a field that declares no + `valueDomain` is untouched, and so is every other field type, because the schema + accepts the key on `text` alone and the validator judges exactly that set. + + **Remedy: write a member of the declared standard.** `iana_time_zone` admits + `UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses + `chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the + `valueDomain` declaration from the field lifts the refusal entirely, for an + author who declared a domain they did not mean. + + **No stored row is touched, and none becomes invalid.** This is the `min` / + `max` / `maxLength` transition-gate class: a value stored before the domain was + declared — or before this release — is never re-read, and it survives an edit of + another field on the same record. An absent or empty value follows the field's + `required` handling, not this check. + + + + - The membership test is the spec's shared `isValueDomainMember` — the same + predicate, over the same closed vocabulary, that a settings specifier's + `valueDomain` uses. A time zone accepted in Settings is the time zone + accepted in a field. + - The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain` + control, shown on exactly the types the schema accepts the key on. The + object-form control's choices are derived from the vocabulary, not re-typed. +- 7778115: `ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. + + `find()` is declared `Promise`, but on the hook path it returned `hookContext.result` with nothing re-checking the value after the `afterFind` dispatch. A handler assigning `ctx.result = { records: [ … ] }` therefore made a read declared to resolve to an array resolve to an envelope instead — silently, with no throw, no diagnostic and no log, while roughly 140 call sites read the answer as an array on the strength of the declaration. + + The engine now refuses that, immediately after the `afterFind` dispatch and ahead of the two consumers that already assume the array (secret-field masking and the `__search` companion strip). The refusal is a named error, `FindHookResultNotArrayError`, carrying the registered ADR-0112 code `FIND_HOOK_RESULT_NOT_ARRAY` and HTTP `500`; its message names the hook event and the object, and `developerMessage` carries the remedy. + + **Shaping stays legal, and nothing about it changes.** A handler may still mutate rows in place, delete keys, filter rows out, or assign a *different array* built from them — `Array.isArray` is the whole predicate, deliberately, so that `ctx.result = ctx.result.map(…)` keeps working. Only the container is protected. + + What to do if this refusal fires: + + - to answer no rows, assign `[]`; + - to refuse the read, `throw` from the handler — the supported way for any hook guard to say no; + - to hand a caller a different structure, build it in the caller, not in the hook. + + `@objectstack/spec` widens by one member: `FIND_HOOK_RESULT_NOT_ARRAY` joins `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the generated `ErrorCode` union — and therefore `ApiErrorSchema.code` — accepts it. Additive: no existing code is removed or renamed. + + Scope: this closes the one `return hookContext.result` site in the engine with a concrete declared shape to violate. `findOne`, `update` and `delete` declare `Promise` and carry no enforceable declaration; that is a separate question about those declarations and is deliberately not answered here. +- f9a3c32: feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + + `BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + + Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + + - **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. + - **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. + - **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + + What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. +- d0ee598: fix(objectql): the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666) + + **BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar + (`registerMetadataCollections`), shipped as `minor` under the repo's + launch-window convention for breaking changes. Ruled on #14666 (2026-09-03, + direction 2). + + An aggregated `defineView` container is keyed by the OBJECT it binds to, not + by its own row identity, and `ViewSchema` declares an optional `name` whose + own description says that for an object-scoped container it *is* the object + name. Nothing enforced that. A container written as + `{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached + the two SOURCE registrars and got opposite answers: this boot loop overwrote + `name` with the derived key `crm_lead` and registered it, discarding the + author's field with no diagnostic, while the artifact/HMR loader + (`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load + through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` / + 400). Same document, and whether it loaded at all depended on how the package + was loaded. + + The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400 + envelope the artifact door raises, naming the container's own `name`, the + object key it derived, and both remedies: drop `name`, or set it to that + derived key. #7378 row 1 already ruled that resolving such a disagreement + silently, in either direction, files the item under a key the caller never + wrote, so the two registrars converge on the refusal rather than on the + rewrite; the artifact door is unchanged. + + **Refused shape**, precisely: an aggregated view container in a stack `views:` + collection that carries a non-empty top-level `name` AND derives a different + object key from its own `object` (or, failing that, `list.data.object` / + `form.data.object`). + + Scope, which the ruling names as this change's main risk. A container with no + `name` is untouched, and still registers under its derived key. So is a + container whose `name` already equals that key, and one that declares no + binding anywhere else, since the derivation then falls back to that same + `name` and cannot disagree with itself. No other metadata kind changes + behaviour: the refusal is gated inside the `views` branch of the generic + registration loop. Standalone ViewItems and flattened overlays travelling in + the assembled `viewItems:` channel are untouched, because a container cannot + reach that channel at all. Every one of these has a control test. + + +- 48b0fcf: `@objectstack/objectql` now publishes a recognizer for the org-less system-write refusal, so a consumer no longer has to choose between an unsound check and a re-spelled string. + + `SystemWriteOrganizationRequiredError` has always documented that it is identified by `code` rather than `instanceof`, "so the check survives crossing a package boundary where two copies of this module can exist". The convention was correct; the affordance for following it was missing. This package declares **both** realms in its own `exports` — `import` to `dist/index.mjs`, `require` to `dist/index.js` — so a consumer that loads it through the other realm than the engine did holds a second copy of the module. Measured across that split from a real consumer package: same class identity (`A === B`) **false**, `instA instanceof A` within one realm **true**, `instA instanceof B` across the two **false**, and a `code` compare **true**. So `instanceof` against this class was unsound for every consumer, and it failed silently — a `catch` that simply never fires. + + That left a consumer with one sound option: re-spelling `'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'` as a literal. That spelling is what `check:error-code-provenance` counts as a stamp site, so recognising one engine refusal cost the consumer's package a provenance decision of its own, and left the string spelled in two places with the typo failure mode standing — a typo in a `catch` produces a branch that never fires rather than an error. + + Two new exports close it, both from the package root: + + - **`SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`** — the code as a value. Same shape as this package's five existing published codes (`DUPLICATE_RECORD_CODE`, `HOOK_TARGET_REBIND_ERROR_CODE`, `HOOK_UNSCOPED_DATA_ACCESS_CODE`, `MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE`, `EMPTY_CREDENTIAL_REFUSAL_CODE`) rather than a new abstraction. The class field now reads from it, so exactly one spelling of the string remains in the package and a typo at an import site is a compile error instead of a dead branch. + - **`isSystemWriteOrganizationRequiredError(err): boolean`** — the code compare itself, so a consumer performs the sound check without authoring the string at all. + + The predicate deliberately returns `boolean` and does **not** narrow to `err is SystemWriteOrganizationRequiredError`. A `code` compare is satisfied by any value carrying that code, including an envelope a transport rebuilt from the wire — #5437 withholds the prose and keeps the machine-readable code — so a type guard would promise `object`, `posture` and `reason` members such a value need not have, moving the unsoundness one layer down instead of removing it. + + ⛔ Nothing about the refusal itself changes: not its `code`, not its 500 status, not when it fires, and not the #8844 `derive-or-refuse` ruling behind it. `SystemWriteOrganizationRequiredError['code']` stays the literal type it was, which is what the existing cross-package consumer types its own constant from. This is purely an addition to what the package publishes. +- e6279dc: The registry's three conflict refusals now publish their error `code` as an importable constant. + + `SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + + Three new exports from `@objectstack/objectql`: + + - `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal. + - `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal. + - `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal. + + **Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary. + + **Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. + + **The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace. +- ec0a6e7: feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) + + A roll-up value has three producers — the insert-time seed, the child-write + recompute, and the one-off backfill — and **declaring a summary field on an + object that already has rows reaches none of them**. For `count`/`sum` the + backfill repairs that as a side effect (every `NULL` is a hole to it). For + `min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the + function alone, so "never computed" and "no child rows" were indistinguishable, + the column stayed `NULL` on every pre-existing parent, and the report said + `filled: 0` — a false all-clear that a timed flow built on the column then + turned into "matches nothing" (the customer case behind cloud#1908). + + **What changes** — maintainer ruling on #15064, option A: the caller who holds + the fact gets a way to say it; the predicate and the default run do not move. + + - `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field` + roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is + walked like a `count`: every `NULL` parent is recomputed through the same + `aggregateSummaryValue` the engine writes. A parent whose aggregate is the + empty-set reading (`null` — no child rows) already holds the engine's own + value, so it is neither counted as a hole nor written; the scoped run is + therefore idempotent in the same "re-run until it reports zero" sense. + Naming a `count`/`sum` is accepted and changes nothing, so a publish path can + pass every column it just declared without knowing the empty-set list. + - A name that resolves to no roll-up owned by an object the run walks — a typo, + a plain field, or an object `objects` left out — is **refused before any row + is read**, dry run or apply, with an ADR-0112 envelope (`code: + 'INVALID_FIELD'`, `status: 400` — the code the projection and write axes + that name a field already answer, while sorting keeps `INVALID_SORT`; + `field` names the first unresolved entry, `fields` all of them). A silent + no-op there would be the same false all-clear this option exists to end. + - `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement + of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an + unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'` + to every roll-up function, since a named `max` now appears in `fields`. + - `os migrate summary-nulls --recompute-undefined-on-empty object.field` + (repeatable) passes the scope through; the confirmation prompt names the + columns; `formatSummaryBackfillReport` lists them under "Recomputed on + request" and explains a `NULL` that remains. + + **What does not change:** without the option the walk, the writes, every + counter and the human-readable report are byte-for-byte what they were (pinned + against output captured on `main` before this change); `min`/`max`/`avg` stay + out of scope and keep being reported under `skippedUndefinedOnEmpty`; the + predicate `summaryNullIsBackfillable` is untouched, so `os migrate + summary-nulls` keeps its meaning on every deployment. The only visible delta on + an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`. + + `minor` for both packages: an optional parameter on a published exported + function, a new report key, and a new CLI flag are each a purely additive + widening of a published surface, which takes at least `minor` (bump-level rule, + 2026-09-04); the `fix`-shaped motivation does not lower it. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- f7ffbd6: `accept-language: zh` now reads a Chinese refusal on the response whose labels are already Chinese. + + `@objectstack/spec` has one locale-negotiation rule — `resolveBundleLocale`: exact match, then case-insensitive, then base language, then **variant expansion**, which is the step that reaches a `zh-CN` bundle from a bare `zh`. `pickData` calls it, and every document translator (`translateObject`, `translateView`, `translateDataset`, …) goes through `pickData`. That is why an app shipping only `zh-CN` still answered `accept-language: zh` with translated object, view and dataset labels. + + The write path's message bridge was the one consumer that never negotiated. `ExecutionContext.locale` is the header's first tag verbatim — `preferredLocaleFromHeader` reports what was *asked for* and expands nothing, deliberately, because each of its callers negotiates differently — and the engine handed that tag straight to `II18nService.t()`. A served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so `zh` missed the `zh-CN` bundle and the English text came back. The result was a half-translated response an app had no way to see coming: the bundle key was present and correct and the coverage gate was green. + + `ObjectQL`'s validation-message context now resolves the requested tag against what the bridged service reports it holds (`II18nService.getLocales()`), through that same `resolveBundleLocale`. The rule is not re-implemented in the engine — the document translators ask it about a bundle's keys, and this asks it about the service's locales. Authored `objects.._validations..message` text, `validation.field.*` overrides and translated field labels all follow, because they read one locale. + + Unchanged: **which** writes are refused, and everything machine-readable about a refusal — the `code`, the `field`, the `constraint`, the status. Only the language of the sentence moves. `preferredLocaleFromHeader` is untouched, and so is every other caller of it. A request with nothing to negotiate against — no i18n service, a service that cannot report its locales, or a tag no variant of which is on offer — passes through exactly as before. + + `ObjectQL.setI18nService` accepts an optional `getLocales?: () => string[]` alongside `t`. `II18nService` has always required `getLocales()`, so every real service already satisfies it; a partial shim that omits it keeps today's behaviour rather than being negotiated against. + +### Patch Changes + +- 4b3955e: fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch + + `BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the + contract half) is one organization for a whole predicate write, or absent. The + only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches + of `update()` / `delete()` — never set it, so every `data.records.updated` / + `data.records.deleted` event read "not asserted" and a tenant-scoped consumer + could deliver nothing per organization on the bulk path. This is the bulk half + of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`) + landed separately. + + The producer now stamps the key from what it already holds — no second query + on the publish path: under `isolated` the caller's active organization (the + Layer 0 wall's equality term), under `group` the caller's membership set when + it names exactly one organization. It is OMITTED — never the caller's active + organization standing in — on a `single`-posture deployment, on an `isSystem` + context (no wall composed), on a multi-membership `group` sweep, when no + enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is + deliberately not consulted), when the caller may have crossed the wall as a + `PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall + does not key on. `absent` here means "the producer did not assert one + organization for the batch", deliberately NOT the `DataEvent` reading + "belongs to no organization". + + Which objects "the wall does not key on", stated exactly rather than claimed as + a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled` + input is true or the object carries no `organization_id`, and it folds THREE + clauses into `tenancyDisabled` — `tenancy.enabled === false`, + `systemFields.tenant === false`, and the deployment's `platformGlobalObjects` + carve-out. The producer reads the registry's binding of that predicate + (`carriesTenantScopeColumn`: the first two clauses plus the column clause) and + answers absent on a federated (`external`) object; a custom + `tenancy.tenantField` is therefore not an exit by itself — the object is walled + iff it carries `organization_id`, and the key follows the wall. The third + clause is deployment-declared and not readable by the engine: a + deployment-exempted object under an armed wall is still stamped with the + caller's organization by this producer alone, and that population's exact + answer is decided by the seam ruled on in #15706. + + `patch`, not `minor`: the act adds no member to this package's published + surface. `carriesTenantScopeColumn` is exported at module level inside + `registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export + named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and + both entries' runtime export lists are unchanged (measured on the built `dist`, + with a firing control) — and the emitted event's member was declared, typed + and paid for at `minor` by the spec half. Producer conformance to an existing + optional member under `fix(` changes no public surface of this package. +- 65846bc: fix(objectql): `DuplicateRecordError.developerMessage` names the wire spelling a client branches on (#14723) + + The envelope's `developerMessage` — the remedy sentence addressed to the + application author — told its reader to "branch on `code === 'DUPLICATE_RECORD'`", + which is the engine's THROWN identity and holds only for an in-process caller of + `engine.insert` / `engine.update`. Every REST route reports the same refusal as + `UNIQUE_VIOLATION`, and since #14723 the per-row reports of the batch and import + surfaces do too, so the sentence was a platform contradicting itself on the one + line an author is most likely to copy. It now says both halves: over the HTTP + API branch on `code === 'UNIQUE_VIOLATION'` on every route, whole-request and + per-row alike; inside the engine the thrown class carries `DUPLICATE_RECORD`. + The class's own docblock says the same. Nothing else about the envelope moves: + `code`, `status`, `cause`, `field`, `object` and the user-facing `message` are + byte-identical, and every pin on the engine's thrown code holds. +- 25a3d91: Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing" + + The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations` + finding from a `type`-only test. The declarative single-record field write + (`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes + for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps + `type` at its default `script`, because the platform action route is where the + write is performed. Every such action was named at every boot and every + `metadata:reloaded` — with a prescription ("add a `body`, or register a handler + under the declared `target`") that parse itself refuses. + + Both readers now read `operation` before `type`, the precedence the runtime doors + already use: the engine inventory, and the authoring-time AI tool-reference rule, + which had diverged from the runtime's listing door and reported a resolvable + `action_` reference as fictional. +- 3bd9b34: feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function + + `packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through + `@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — + re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure + inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> + `chokidar`, plus `glob`, `js-yaml` and `readdirp`. + + The same file already carried the answer 79 lines above, at its + `@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a + cross-package consumer gets the predicate without the manager, the loaders or + the YAML/filesystem machinery behind the root entry". This is that pattern, + taken a second time. + + **Measured on the built artifacts, not asserted** — every module Node actually + evaluates when `@objectstack/objectql/core` is loaded in a fresh process, + recorded through a `module.registerHooks` load hook (ESM and CJS) plus + `require.cache`, byte sizes from `statSync`: + + | `@objectstack/objectql/core` | modules | bytes | + |:---|---:|---:| + | before (ESM `dist/core.mjs`) | 190 | 12,348,424 | + | after (ESM `dist/core.mjs`) | 185 | 11,849,808 | + | **delta** | **-5** | **-498,616 (-486.9 KiB)** | + | before (CJS `dist/core.js`) | 188 | 12,654,238 | + | after (CJS `dist/core.js`) | 183 | 12,141,034 | + | **delta** | **-5** | **-513,204 (-501.2 KiB)** | + + Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), + `js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and + `readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal + module-init time for that root entry, measured on a warm lean closure, was + ~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. + + ⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about + the delta and wrong about the baseline: the lean entry's closure was already + ~11.5 MiB before this import existed, dominated by `@objectstack/spec` + (9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry + contributes. What the root import cost was ~487 KiB *on top of* that, not a + closure of 450 KB. + + The derivation itself moves to `packages/metadata/src/view-container.ts`, a + module with **no imports at all**, and `view-container-expansion.ts` imports + and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their + spelling and the symbol stays on the root entry — this subpath is an additional + door, not a relocation. A re-export shim onto `view-container-expansion.ts` was + tried first and rejected on measurement: esbuild tree-shakes the unused + `expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import + statements, so that shim's own closure was 84 modules / 3,035 KiB. The real + leaf's is 1 module / 469 B. + + `expandRuntimeViewContainer` is deliberately not exported from the new subpath: + `metadata-manager.ts` is its only caller, the root entry does not export it + either, and it is the half that carries the spec machinery. +- e9fcd6b: fix(objectql): the declarative hook wrapper reads the renamed `hook.timeoutMs` (#14478) + + `wrapDeclarativeHook` reads its wall-clock abort budget from `meta.timeoutMs` + instead of `meta.timeout`, following the `@objectstack/spec` rename of the + authored key (the unit now lives in the key name). Same value, same magnitude, + same abort; no public surface of this package changes. +- 26144c2: The platform-object tenancy census is derived and gated instead of hand-written in a comment. Documentation only — no runtime behaviour changes. + + `PLATFORM_OBJECT_TENANCY`'s header explained why the reclassification needs a ledger rather than a schema read, and backed the argument with three hand-written digits and a parenthetical attributing them. Nothing re-derived any of it, so it was true only until the population moved and failed silently when it did — in both of the directions a prose count can. + + The parenthetical mis-attributed the exclusion: it named `sys_sso_provider`'s `tenancy.enabled: false` as an addition to the `managedBy: 'better-auth'` set that object was already in, and left `sys_api_key`'s identical opt-out unnamed. The arithmetic stayed right, which is why no reader and no gate caught it — a wrong reason producing a right total is the shape that survives longest. The digits then went stale when an object opted out of the tenant column through a third mechanism the parenthetical's taxonomy had no slot for (`systemFields: { tenant: false }`), while the gated page next door was updated in the same commit. + + The digits and the parenthetical are deleted rather than corrected. The header now points at `scripts/platform-object-tenancy-census.json` and states the PREDICATE it was missing: an object is inside the machinery when `resolveTenantFieldName` answers non-null on the **registered** schema — after `applySystemFields` has injected the tenant column, because the injected column is what the engine sees, not what the author typed. Counting `managedBy` as if the resolver read it is the mistake that produced the wrong reason. + + The artefact is derived by `scripts/platform-object-tenancy-census.mjs`, which loads `resolveTenantFieldName` and `resolveInjectedSystemColumns` from source and executes them rather than re-spelling what they decide, and is held to the tree by `scripts/check-platform-object-tenancy-census.mjs`. It records per object the declaration on that object's own schema that puts it outside the reach; declarations are not mutually exclusive and an object carrying two keeps both. An excluded object with no declared mechanism is an error, not a default: the generator refuses to commit the row and the gate reds, so a new exclusion mechanism is adjudicated rather than absorbed into an existing total. +- d61d6e3: `os migrate value-shapes` now prescribes the key rename on a legacy `{latitude, longitude}` location, instead of reporting the missing-pair type error. + + A value-shape rejection was read positionally — `parse.error.issues[0]` — at both places the value-shape detail is produced: the write path's warn-first / strict branch, and the exported `valueShapeViolation` the scan imports. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a value whose keys were **renamed** the actionable message sorts last and was discarded. A `location` stored as `{latitude, longitude}` — the exact legacy shape the scan's own header names as one it exists to find — reported `Invalid input: expected number, received undefined`, leaving an operator to derive a rename that edit distance cannot reach (`latitude` -> `lat`), while `LocationValueSchema` had built the prescription and thrown it away. + + Both readers now prefer the undeclared-key issue when the rejection carries one, through a single shared helper — two readings of the same rejection drifting by one clause is how one path prescribes the rename and the other does not. The affected strings are the `os migrate value-shapes` finding `detail`, the warn-first `[value-shape]` log line, and the `invalid_value_shape` error's `detail` under strict enforcement. + + ⛔ No verdict moves. The same values are flagged, the same writes are rejected or admitted, and the deployment gate opens on exactly the same evidence — only the operator-facing text changes. + + Scoped by measurement rather than by assumption: of the sixteen types these readers cover, only `location` and `address` are backed by a key-closed object schema, so only they can emit `unrecognized_keys` at all — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half. The defect reaches `address` as well as `location`: every address member being optional rules out a *missing*-member type error, but not a *wrong-typed* declared one, which still sorts ahead of the undeclared-key issue. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [a56baa2] +- Updated dependencies [65846bc] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [e1d4f9e] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [efc5447] +- Updated dependencies [618f70d] +- Updated dependencies [4b0508e] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [7d711c9] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/metadata-protocol@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index c3d6b44252..d80cba45bf 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Isomorphic ObjectQL Engine for ObjectStack", "main": "dist/index.js", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index abc6646646..91edc33e9d 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,76 @@ # @objectstack/observability +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 9a7b7963c7..e9dde2649c 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.", "type": "module", diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index 5c6a352b30..187aace3d4 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,305 @@ # @objectstack/platform-objects +## 17.4.0 + +### Minor Changes + +- 4ca358d: `sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out + + Removing a member deleted the `sys_member` row and left the session alive, for up to seven + days. #15409 closed the security half per request (a session whose `activeOrganizationId` + is not backed by a membership resolves with no active organization). This is the courtesy + half an admin was promised, and it is **never the enforcement**: a trigger can be missed, + an evaluation cannot. + + - **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening + on a published system object, hence `minor` on `@objectstack/platform-objects`. Every + reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an + interactive revoke (`user_revoked`, `admin`); this is the first authorization-event + cause. There is no Zod enum behind the column — it is free `text` — so the field's own + description is the published vocabulary, and that is where the value is declared. The + string deliberately matches the one the API-key arm of the same ruling family already + mints for this event (`authRefusal.reason` in `resolve-authz-context.ts`), so one grep + finds every place the platform acts on a membership ending. + - **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, + decision batch #49 item 4, option B). A user who still holds another membership is + **re-pointed** to it — never signed out of organizations they legitimately belong to. A + user with no remaining membership has their session revoked through the existing + `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns + nothing on the next request and the Console's existing 401 → login redirect handles it, + with **no client change**. + - **The seam is an engine hook on `sys_member`**, not a hook on better-auth's + `/organization/remove-member`. A census measured that the endpoint, a direct delete, a + bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach + the hook, while an endpoint hook would have reached one of them. Same precedent as + `last-admin-guard.ts`. + - **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, + `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence + `minor` rather than `patch`. + + Known open by measurement, not by omission: a raw driver delete bypasses the trigger + entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The + per-request check covers it; the courtesy does not. +- 6acb37e: feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) + + + + Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. + + **The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. + + **What lands.** + + - `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. + - `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. + - plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. + + **The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. + + **Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. + + **One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. + + **Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. + +### Patch Changes + +- 159dbad: `attestFreshDatastore` looks its `os migrate` remedy up instead of defaulting it + + When a fresh datastore's own boot has already admitted a value that contradicts a + migration's contract, that id is not attested and the operator is told what closes + the gate on real evidence. The sentence used to be built from a two-way branch: the + file-references id got `files-to-references`, and **every other id** got + `value-shapes` by default. + + `CREATION_ATTESTED_MIGRATION_IDS` has three members. For the third — + `adr-0030-notification-event` — that default is a wrong prescription: `os migrate + value-shapes --apply` neither attests nor clears it, and there is no `os migrate + notification-event` sub-command to send an operator to at all (that cut-over is an + operator call with no self-check). + + The branch is now an explicit id-to-remedy register, total over the ids a + value-shape tally can contradict. The loop asks it rather than falling into an arm, + so an id with no value-shape contract is never-contradictable by that evidence and + is attested on the birth observation as before. A new member therefore inherits no + remedy: adding a third arm that happened to be right today would only have moved the + same defect onto the fourth member. + + No behaviour changes for the two ADR-0104 ids, which is where every reachable path + runs today: the shipped engine keys its admitted-violation tally from a closed + `'media' | 'value-shape'` union, so it cannot name a third id. +- 85a2459: fix(spec): the dashboard `gap` field no longer describes itself to app authors in Tailwind vocabulary + + `ui/dashboard`'s `gap` key told app authors its value in the vocabulary of a CSS + library they never chose and cannot act on. **Two** independent producer strings + carried that wording, and they feed two independent customer-facing surfaces: + + - `dashboardForm`'s `helpText` — `Grid gap (Tailwind units)` — rendered verbatim in + the Studio property panel, which is spec-driven and feeds this form straight into + the generic form renderer. + - `DashboardSchema.gap`'s `.describe()` — `Grid gap in Tailwind spacing units` — + rendered as this field's row in the published reference page + `content/docs/references/ui/dashboard.mdx`. The reference corpus renders + `.describe()`, never `helpText`. + + Both now read **Space between widgets, in steps of 0.25rem (4 = 1rem)**: what the + author decides, plus the magnitude, stated in a CSS unit instead of a framework's + scale. The magnitude had to survive the rewrite rather than be dropped with the + framework name — the number is a spacing step, so `4` means `1rem` and not `4px`, + and an author who lost that would come away knowing less than before. + + The step size is stated as measured rather than inferred: the dashboard renderer + sets the grid gap as an inline style computed from this key, so every accepted + value is linear and one step is exactly `0.25rem`. "Tailwind units" was doubly + wrong — it named an implementation dependency, and it named one the consumer of + this key does not have. + + **No schema change.** `gap` stays `z.number().int().min(0).optional()` and accepts + exactly what it accepted before; nothing is added to or removed from any public + surface. `columns` is deliberately untouched on both of its producer lines — + `12` is an author-visible fact about the grid being laid out, not a framework + detail — and this is one field's two strings, not a sweep for framework words. + + The `en` metadata-forms translation bundle is a mechanical copy of the form source, + so it is regenerated to match. Translated locales are not touched: regeneration + fills gaps only and never overwrites an existing leaf. +- cbca47d: fix(platform-objects): the es-ES and ja-JP `dashboard.gap` help text says what its source now says + + `metadataForms.dashboard.fields.gap.helpText` read `Separación de cuadrícula (unidades + Tailwind)` in es-ES and 「グリッド間隔(Tailwind 単位)」 in ja-JP. Both were faithful + translations of the source they were extracted against, `Grid gap (Tailwind units)` — but + that source has since been rewritten to `Space between widgets, in steps of 0.25rem + (4 = 1rem)`, which deliberately drops the CSS framework unit an app author never chose and + cannot act on, and adds the magnitude the author can size a dashboard with. + + Both leaves kept the retired vocabulary and never gained the magnitude, because bundle + merge fills gaps only: a present-but-stale leaf is not a gap, so no amount of + re-extraction corrects it. They now read `Espacio entre widgets, en incrementos de 0.25rem + (4 = 1rem)` and 「ウィジェット間の間隔、0.25rem 刻み(4 = 1rem)」 — the grid framing is gone + exactly as it is upstream, `widgets` / 「ウィジェット」 is the word each bundle already uses + for dashboard widgets, and the conversion is carried so a Spanish- or Japanese-reading + author can size `gap` without reading the English. + + Two leaves. `columns` is unchanged upstream, so `Columnas de cuadrícula (predeterminado + 12)` and 「グリッド列(既定 12)」 stay accurate, and the other 13 source-derived prose leaves + of this subtree (5 section descriptions plus 8 further field help texts) were read against + the current English and are accurate in both locales. +- e9fcd6b: fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) + + The `metadataForms.dashboard` translation bundles key the auto-refresh field as + `refreshIntervalSeconds`, following the `@objectstack/spec` rename of the + authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the + hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across + the rename unchanged, because the field still means what it meant and each help + text already named the unit. +- 2bb0614: fix(platform-objects): `sys_email.error` field help now covers pre-delivery rejections, not only transport failures + + `sys_email.error` was declared as *"Transport error message when status=failed"*. + Since `EmailService.recordRejectedMessage` landed, the same column also carries + the reason a message was rejected by `normalizeMessage` **before** it reached a + transport (an unsendable `from`, no recipient, no subject, no body) — those rows + are written with `status: 'failed'` too, prefixed `rejected before delivery: `. + + Nothing was misleading in the *data*: the row prefixes its own reason, so an + operator reading a failed row is never sent chasing an SMTP host for a message + that never reached one. What was stale was the field's declared `description`, + which Studio surfaces as the field's help text — it named only the transport + case, narrower than what the column has held since that change landed. + + The description now reads: *"Why the message failed — a transport error, or the + validation that rejected it before delivery."* It stays true under both row + shapes and deliberately does not name the row's own `rejected before delivery:` + prefix, so it will not go stale again if that prefix's wording changes. +- b3820c3: `sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629) + + The list read `['subject', 'to', 'status', 'sent_at']`. Three of those four resolve; `to` does not — `sys_email`'s recipient column is `to_addresses`. It now reads `['subject', 'to_addresses', 'status', 'sent_at']`, and nothing else about the object moved. + + `highlightFields` is the object's ordered "most important fields" pointer (ADR-0085): it drives the default list columns, record cards, previews and the detail highlight strip. Every consumer **silently skips** an entry it cannot resolve — nothing throws and nothing logs — so each of those surfaces rendered one field short, and the field missing from the platform's own outbound-email log was the recipient. + + There was a second, louder consequence that nobody could reach by accident. Since `object-field-ref-unknown` crossed onto the object write door (#15254), this body could not be republished through `PUT /api/v1/meta/object` or a package publish: the door answers `422 INVALID_METADATA`. `sys_email` reaches the runtime as a code-shipped registry object instead — `EmailServicePlugin` hands it to the manifest service, a path that runs no authoring gate — so boot was never affected and no deployment was failing. It was a trap laid for whoever next edited the object through a door rather than the file. + + `sys-email.highlight-fields-resolve.test.ts` pins it through that real door rather than by comparing the array against `Object.keys(fields)`: it runs `runRuntimeAuthoringRules({ type: 'object' })` over the shipped declaration with the audit module's other objects as resolution context, and a control case restores the old entry and requires the same call to refuse it — so a green result means the door read this object and accepted it, never that nothing looked. +- 021a735: fix(platform-objects): the zh-CN `dashboard.gap` help text says what its source now says + + `metadataForms.dashboard.fields.gap.helpText` in `zh-CN.metadata-forms.generated.ts` + read 「栅格间距(Tailwind 单位)」. That was a faithful translation of the source it was + extracted against, `Grid gap (Tailwind units)` — but the source has since been rewritten + to `Space between widgets, in steps of 0.25rem (4 = 1rem)`, which deliberately drops the + CSS framework unit an app author never chose and cannot act on, and adds the magnitude + the author can size a dashboard with. + + The leaf kept the retired vocabulary and never gained the magnitude, because bundle merge + fills gaps only: a present-but-stale leaf is not a gap, so no amount of re-extraction + corrects it. It now reads 「组件之间的间距,每级 0.25rem(4 = 1rem)」 — `widgets` is + 「组件」 as it is everywhere else in this bundle, the grid framing is gone exactly as it is + upstream, and the conversion is carried so a zh author can size `gap` without reading the + English. + + One leaf. `columns` is unchanged upstream, so 「栅格列数(默认 12)」 stays accurate, and + the other five leaves of this subtree were corrected separately. +- 7bdb163: fix(platform-objects): 21 zh-CN metadata-form leaves say what their source says + + `zh-CN.metadata-forms.generated.ts` carries 615 leaves that differ from `en` and hold no + digest in `zh-CN.source-hashes.generated.ts` — LEGACY-TRUSTED values carried in from a + pre-consolidation hand vocabulary (`e0077ea36` deleted a 746-line + `src/metadata-translations/zh-CN.ts` and imported its strings) and never reconciled + against the English the same commit range seeded. A census of all 613 (as the population + then stood) found 26 that assert something the source does not, or drop a distinct concept + the source names. Five of the 26 — the whole `dashboard` subtree — landed in `9f57f1e31`. + These are the remaining 21. + + They are not stale fills and no gate can see them: a stale fill is a byte copy of a + previous source revision, detectable by cross-locale agreement or a recorded digest, and + these are neither. Nor does re-extraction correct them — bundle merge fills gaps only, and + a present-but-wrong leaf is not a gap. + + Three defect kinds, all decided against this bundle's own usage: + + - **Asserts an input that does not exist.** `skill.sections.triggers.description` promised + 「触发关键词」 for a section holding only `triggerConditions` (`triggerPhrases` was + removed with the key); `email_template.fields.variables.helpText` promised a per-variable + 「默认值」 that `EmailTemplateDefinitionVariableSchema` does not declare; + `action.sections.advanced.description` promised 「批量」 after `bulkEnabled` was removed + from that section. + - **Names the wrong technology.** `action.fields.body.helpText` said the body is + 「JavaScript 代码」; an L1 expression is not JavaScript. It now reads + 「L1 表达式或 L2 沙箱 JS 体」 — verbatim the sibling `hook.fields.body.helpText`, which + translates the identical source sentence correctly. + - **Drops a distinct concept the source names.** `object.fields.isSystem.helpText` dropped + 「共享默认为公开」; `view.fields.filter.helpText` reduced a sentence about the shared + visual builder to 「筛选规则」; `permission.sections.identity.description` dropped both + sentences explaining how permission sets stack on profiles. + + zh-CN only: es-ES and ja-JP are untouched here. The 18 looser paraphrases the census + excluded are also untouched. +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 57442849b7..ac1b26ac7f 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects", "main": "dist/index.js", diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index 882de324f1..38f9c33243 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,76 @@ # @objectstack/embedder-openai +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 2ea074f9a1..3306dde12b 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md index d17acf6db1..c9020be8c4 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/knowledge-memory +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/service-knowledge@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 27234013b7..22f1320ef0 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md index 60dfc5ff23..8dc51d7de5 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/knowledge-ragflow +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/service-knowledge@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 028ca63c07..00a2ff6996 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index e274790c75..49c56e85b8 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,132 @@ # @objectstack/plugin-approvals +## 17.4.0 + +### Minor Changes + +- 6530e04: A restored approval suspension can now be decided again, not only cancelled. + + `AutomationEngine.restoreConsumedSuspension` re-arms the pause of a run that stranded mid-resume and tells the operator to *re-issue the continuation*. For an `approval` suspension nobody could: every approvals door that stamps the resume marker — `decide`, `recall`, `sendBack`, `resubmit` — guards on a `pending` request, and the row is terminal, written by the very call that stranded the run; and the generic engine door refuses an `approval` pause outright, because that node declares `resumeAuthority: 'service'`. The only remaining verb was `cancelRun`, which discards the branch's downstream work — so the advertised repair produced a run that looked resumable and was not decidable. + + Measured against the real engine and the real decision door: the restored suspension lacks nothing. A `resumeAuthority`-marked resume walks the restored pause to completion. What was missing was an **issuer** on the approvals side, and that is what this adds. + + - **`ApprovalService.continueRestoredRun(requestId, options?)`** re-issues the continuation the recorded outcome already produced once, against a pause an operator has re-armed. It reports which outcome it replayed, which edge it walked, and whether the signal was replayed exactly or rebuilt (`source: 'journal' | 'reconstructed'`). + - **The failing door now journals the signal it was carrying** on the repairable exit — the engine's own `status: 'stranded'` discriminator, the one exit that journals a repair snapshot — under `__strandedContinuation` in the request's `node_config_json`, beside the `__decisionOutputs` side-channel that was already there. Best-effort: it is awaited but can never replace the `RESUME_FAILED` throw the decision's caller is owed. + - **The continuation is tied to this request's own pause, by three guards.** A boolean "is this run suspended" is not enough: a run outlives any one request, so a terminal row's continuation could be issued against whatever pause the run happened to be sitting on. It now requires that the request is still the newest on its run, that a pause exists (strictly — an unreadable store throws rather than reading as "not suspended"), and that the pause is parked **where this request's recorded outcome was issued from**. That node is signal-aware, not simply the row's own: `approve`, `reject`, `revise` and `recall` are all issued at the request's own approval node, but a `resubmit` is only ever issued from the revise window the request's `revise` edge leads to, so its pause is re-armed there while the row still records the approval node. Comparing against the row's own node refused exactly that case, and told the operator the pause was not this request's when it was. The node check is fail-closed in every direction, including an engine that cannot report where a run is parked and a revise window this service cannot derive from the flow definition. This needs no new automation-engine surface: `listSuspendedRunsDurable` is already public, and the approvals-side resume interface simply declares it. + - **Runs stranded before this shipped are served too**, and where the signal cannot be proved the verb **refuses instead of guessing**. A status is not the same thing as a continuation, and three of the four terminal statuses have more than one writer or issuer: `approved` is unambiguous; `rejected` has two writers, discriminated by the `revise` action row that only ADR-0044's revision-limit auto-rejection leaves behind; `returned` has one writer but **two** issuers, discriminated by the `resubmit` action row whose sole writer is `resubmit` — without it a stranded resubmit was rebuilt as a send-back and walked the wrong edge, proceeding only through the engine's unmatched-label fallback with the wrong output; and `recalled` has two writers across **three** behaviours, two of which issue no continuation at all, so it is **refused on the rebuild path** with a message naming what an operator can do instead. Journal-recoverable is a **measured, named set** rather than a blanket claim: `approve`, `reject`, `resubmit` and `recall` continuations replay end to end through the verb, and `reject` and `resubmit` do so on the rebuild path as well. Two shapes are refused by design and stay refused — a `rejected` row that also carries a `revise` action, and a `recalled` row with no journal. NOT covered by a pin, and so not claimed: the `approve` rebuild path. + + - **A journalled signal is checked against what the row's status can have issued, before it is replayed.** The journal records what the last FAILED resume was carrying, and nothing rewrites it when a later door moves the row on — so a signal can outlive the state that issued it. Measured, with no injected failure beyond the strand: a `resubmit` strands and journals `resubmit`; the submitter then recalls, a real `cancelRun` on an already-stranded run answers `false`, the row is marked `recalled` and the run stays parked; the restore re-arms the pause; and the stale `resubmit` was replayed, opening a fresh `pending` round on a request somebody deliberately withdrew. Every step an ordinary action answering ordinarily. A row is now replayable only for a continuation its own status can have issued — `approved`→`approve`, `rejected`→`reject`, `returned`→`revise` or `resubmit`, `recalled`→`recall`, and nothing at all for a status nobody has enumerated. ⛔ Clearing the journal after a successful replay does not close this and was measured not to: the offending replay is the FIRST replay of that journal, so a clear that fires afterwards can never run before the advance it would prevent. + + ⛔ What this deliberately does not do, each pinned: it does not re-open or rewrite the request row — all four `pending` guards are untouched and no status, mirror field or audit row is written, so a decided request still cannot be decided again through the front door; it does not relax `resumeAuthority: 'service'`, since the resume still goes through the one call site that stamps the marker; and it does not change `ApprovalDecisionResult`, whose shape is the subject of an open ruling. It also grants no capability in-process code did not already have — `RESUME_AUTHORITY_SERVICE` is importable by any host — what it adds is the guarded form, and the guards are stated as what they actually check: that this request is still the newest on its run, that a pause exists at all, that it is parked where this outcome was issued from, and that the recorded signal is one the row's present status can have issued. ⛔ None of them checks that the pause was consumed and genuinely re-armed, and an earlier wording of this entry claimed one did: a `returned` row with a resubmit action row and a pause that was never consumed is admitted, with `restoreConsumedSuspension` itself answering *"already resumable — nothing to restore"*. That shape is benign — the recorded action is the submitter's own resubmit, so the step it walks was decided — but it is not what any guard tests. Like the engine verb it completes, it is an in-process operator repair: no REST route, and no entry in the spec `ApprovalService` contract. +- 3d3f60e: An approval decision that lands while its flow run strands now says so in fields, not only in prose. + + `POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. + + The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: + + - **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. + - **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. + - **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. + + `@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. + +### Patch Changes + +- ea03c7c: Fix: a `department` approver on a seeded business unit no longer routes the approval to another organization's members. + + `ApprovalService.expandBusinessUnitUsers` screened the `sys_business_unit` rows with the null-inclusive tenant predicate (#3807 — a seeded unit carries no organization and is admitted on purpose) but read `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either. A seeded unit id exists identically in every tenant, so a `department:` approver on tenant A's request resolved the shared unit and then collected every tenant's membership rows hanging off it — approval authority over A's record, routed to B's users. The member read now carries a strict `organization_id` equality against the directory organization the approver resolves in: the same screen `plugin-sharing` applies to these rows, and the same posture this package already takes for `sys_team_member` and `sys_user_position`. + + The screen is strict rather than null-inclusive on purpose. `sys_business_unit_member.organization_id` is filled by REST/session writes but left NULL by seed replay and by elevated system-context writes (tracked in #14570), so a NULL on a membership row means unknown tenancy, not "platform-global", and routing fails closed on it. Declared cost: on a deployment whose membership rows (not merely its units) were seeded or system-written, a `department` approver on a request that carries an organization now expands to nobody — the slot falls to the `department:` literal, the existing `expanded to nobody` warning (#3807) names it, and `onEmptyApprovers` governs the request as for any unstaffed target. The repair is to stamp those membership rows. A request that carries no organization is unchanged, and so is every unit-level screen. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 4af6799ad2..b5da20d215 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md index 1482cae17b..83c8a42b0e 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,109 @@ # @objectstack/plugin-audit +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [a56baa2] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e6279dc] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 6a369ad2f3..ed2780c46b 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", "main": "dist/index.js", diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md index 016275eb38..0399b52d53 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,360 @@ # Changelog +## 17.4.0 + +### Minor Changes + +- 4ca358d: `sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out + + Removing a member deleted the `sys_member` row and left the session alive, for up to seven + days. #15409 closed the security half per request (a session whose `activeOrganizationId` + is not backed by a membership resolves with no active organization). This is the courtesy + half an admin was promised, and it is **never the enforcement**: a trigger can be missed, + an evaluation cannot. + + - **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening + on a published system object, hence `minor` on `@objectstack/platform-objects`. Every + reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an + interactive revoke (`user_revoked`, `admin`); this is the first authorization-event + cause. There is no Zod enum behind the column — it is free `text` — so the field's own + description is the published vocabulary, and that is where the value is declared. The + string deliberately matches the one the API-key arm of the same ruling family already + mints for this event (`authRefusal.reason` in `resolve-authz-context.ts`), so one grep + finds every place the platform acts on a membership ending. + - **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, + decision batch #49 item 4, option B). A user who still holds another membership is + **re-pointed** to it — never signed out of organizations they legitimately belong to. A + user with no remaining membership has their session revoked through the existing + `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns + nothing on the next request and the Console's existing 401 → login redirect handles it, + with **no client change**. + - **The seam is an engine hook on `sys_member`**, not a hook on better-auth's + `/organization/remove-member`. A census measured that the endpoint, a direct delete, a + bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach + the hook, while an endpoint hook would have reached one of them. Same precedent as + `last-admin-guard.ts`. + - **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, + `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence + `minor` rather than `patch`. + + Known open by measurement, not by omission: a raw driver delete bypasses the trigger + entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The + per-request check covers it; the courtesy does not. +- 6acb37e: feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) + + + + Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. + + **The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. + + **What lands.** + + - `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. + - `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. + - plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. + + **The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. + + **Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. + + **One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. + + **Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. +- 8e0b297: fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) + + + + **BREAKING** meaning change on a published payload — `user.positions` in + `GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's + launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on + #15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one + name, one meaning. + + `customSession` built the array from the better-auth `sys_user.role` scalar + split on commas, plus the active membership mapped to `org_*`, plus + `platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 + D4 table that is the source of truth for custom positions. The Console binds + that array straight through as the CEL root `current_user`, so an + `action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed + by a business position answered FALSE for **everyone**, including the user who + genuinely held it. + + ⭐ It failed **silently and in the invisible direction**: the root was bound and + the key was present, so `has(current_user.positions)` was true, CEL raised + nothing, and the predicate simply returned FALSE. A predicate that *faults* + fails OPEN in the shell and would have shown the button; a successful FALSE + shows nothing and reports nothing. The documented example + (`'org_admin' in current_user.positions`) kept working throughout, because + `org_admin` is the one name that sits on **both** axes. + + This was a **declared** contract being violated, not an ambiguous name: + `EvalUserSchema` already specified `positions` as "built-in identity names + + position names", exposed to "every predicate surface (server formula, server + RLS, client UI gates) ... with an identical shape" so that a predicate + "evaluates identically wherever it is written". `/auth/me/permissions` and + every server-side evaluator (`ExecutionContext.positions`) already resolved the + security axis; only the session payload did not. + + **What changes** + + - `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, + not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE + authority (`core/security/resolve-authz-context.ts`, whose header forbids + every entry point from re-reading the `sys_*` grant tables itself), scoped to + the session's active organization. The payload therefore carries the + `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and + agrees with `/auth/me/permissions` set for set. Same move + `isPlatformAdminUserId` made at #10348. + - `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as + an alias of `'platform_admin' in positions`), so one authority answers both. + - `packages/spec` — `EvalUserSchema` states which axis `positions` is, and + states that the better-auth role scalar is not it. + + **No key is renamed, and none is added.** The ruling anticipated a renamed + auth-role array; measured against the tree, it has no content to carry and no + consumer. Everything the old union contributed beyond the security axis was the + `sys_user.role` scalar's own tokens — and that scalar is **already published, + unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban + carves out, for third-party schema this platform does not own). Minting a + `roles` array would revive that banned word to publish information the payload + already carries. (Precisely: `check:role-word` ratchets the reserved word in + `content/docs` and `skills/` PROSE, while the identifier ban over authored + metadata lives in `packages/lint`; a TypeScript payload key trips neither + mechanically until it is documented. The ADR-level prohibition is what rules + here, not a gate that would have caught it.) A consumer that wants the + better-auth role reads `user.role`. + + **What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); + `platform_admin` still derives from the unscoped `admin_full_access` grant with + its ADR-0091 validity window and ADR-0049 active flag intact — + `platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over + those shapes. + + ⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** + `positions.includes('platform_admin')` is the form + `resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` + row may spell that very name — and this card is what made that reachable, by + moving `positions` onto an axis a tenant admin can write. Reading the name would + have let a tenant mint platform standing and pass the `/admin/*` mount gate. + `platform-admin-gate.ts` drops its positions leg for the same reason. + `session-platform-admin-rung-agreement.test.ts` requires the payload alias, that + gate and `hasPlatformAdminStanding` to agree, driven with such a row present and + a genuine grant as the control. + + **Upgrade.** If you gate on the better-auth role scalar, read `user.role` + instead of looking for its tokens in `user.positions`. Predicates written + against real position names, built-in identity names, or `everyone` need no + change — they start working. Deployments that stored business role names in + `sys_user.role` rather than assigning positions should assign them through + `sys_user_position` (the governed ADR-0090 D12 channel). + + A name in `sys_member.role` is still projected, **with one carve-out**: for a + session carrying NO active organization, membership names are now *added*, from + **every** membership the user holds — the resolver projects them all when no + tenant scopes it, where the old derivation contributed none. Measured on the + real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): + `[]` before, `[org_admin, everyone]` after, pinned by + `session-positions-security-axis.test.ts`. With an active organization the + projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so + membership-derived names there are unchanged. +- aedbaef: `POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) + + **This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened. + + ### What was measured + + Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at. + + ### The mechanism + + better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear. + + The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture. + + ### The fix + + The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically. + + **Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing. + + **Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt. + + `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent. + +### Patch Changes + +- d4c2cb1: The auth catch-all yields only a 404 that disclaims ownership — better-auth's own 404 answers can no longer be replaced by another route's + + `registerAuthRoutes` mounts one catch-all over the whole auth namespace (`rawApp.all(`${basePath}/*`)`), and since #4088 that catch-all is deliberately not terminal: when better-auth answers 404 it calls `next()` and lets whatever else matched answer instead. That yield is load-bearing — `plugin-hono-server` mounts `/auth/me/permissions` and `/auth/me/localization` from its own `kernel:ready` hook, and without it those two are reachable only when HonoServerPlugin happens to register first. + + What the yield could not express is **which** 404 may be handed on, because it had only the status to go on. So every 404 was yielded, including the ones that are better-auth's own answer on a path its router serves. Measured with the shipped handler on a real Hono app: add one broad downstream mount — `app.all('/api/v1/*', c => c.json({}))`, the shape a composition adds — and + + ``` + POST /api/v1/auth/delete-user -> 200 {} + ``` + + where better-auth answered 404 because `user.deleteUser` is deliberately unconfigured. That route is not hypothetical: `auth-route-ledger.ts` carries it under the `disabled` disposition precisely because it is published and refused — and the same holds for every 404 a routed endpoint produces for a bad token, an unknown id, or an admin family the deployment does mount. Those answers were all up for grabs. + + The catch-all now asks better-auth's live instance whether it owns the path before it yields. The seam is `auth.api` — the same one `auth-route-ledger.conformance.test.ts` reads and the same one the `/admin/` dogfood sweep derives from, because there is no route table to enumerate by hand; matching mirrors better-call's own `createRouter` walk, including its `SERVER_ONLY` skip and its `:param` syntax. That skip is load-bearing rather than cosmetic: measured on the stock boot, the nine `/admin/oauth2/*` endpoints are in `auth.api` and every one carries `SERVER_ONLY: true`, so better-call never routes them — their 404 is an unrouted one and stays yieldable, because ownership is "does better-call route this", not "is it in `auth.api`". An ownership table that cannot be built answers "not owned", so an enumeration failure degrades to the previous behaviour rather than taking the #4088 surface down with it. + + **The mount is untouched.** It still claims exactly `${basePath}/*` and still forwards every request under it to better-auth. What narrowed is only which 404 may be handed on. + + **Upgrade note — a composition that mounts a route matching paths under the auth base path may see a 404 where it previously saw its own answer.** Affected: deployments that register a route which also matches `/api/v1/auth/...` — most often a broad wildcard over the API prefix — mounted *after* AuthPlugin. Before this release, any request to a path better-auth serves but answers 404 on (a switched-off capability, not an unknown path) was passed to that route and the caller received *its* response, commonly `200` with an empty object. From this release the caller receives better-auth's 404. Callers that treated such a response as success — `res.ok`, `status === 200`, "no error thrown" — will start seeing the refusal that was always the real answer; that is the point of the change, and the wire shape they now get is the one a deployment without the extra mount has always returned. Nothing to do if you mount no such route: paths better-auth does **not** own are yielded as before, so `/auth/me/permissions`, `/auth/me/localization` and any other sibling route under the auth prefix are unaffected in either registration order. + + **One carve-out to that sentence, measured and bounded.** A **trailing-slash or doubled-slash spelling of a path better-auth DOES own** — `/api/v1/auth/delete-user/`, `/api/v1/auth//sign-in/social` — is now claimed rather than yielded. better-call treats those spellings as unrouted (it refuses on a `//` and on trailing-slash parity before it looks the route up), while this ownership table strips the trailing slash and drops empty segments and so counts them as owned. On a composition with a broad downstream mount, such a spelling therefore answers better-auth's 404 instead of that mount's response. Only those two spellings, only of a path better-auth already owns, and only where such a mount exists: no route in this repo registers a spelling of that shape, and every genuinely unowned path — every `/auth/me/*` route included — is yielded exactly as it was. Aligning the table with better-call's own pre-checks is tracked as a follow-up rather than carried here. +- 8e500f2: The `no_sign_in_account_at_boot` report now names a remedy that works — and warns off the one that silences the report itself. + + That boot line fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said: + + - **"Open the audience posture so an existing person can register their own login"** produced no login, and for an existing person it never can: self-registration is a user-creation path, so it cannot attach a login to an address that already carries a `sys_user` row, whatever the posture. Widening only ever admits a *new* address — and then every posture other than `invite_only` forces `requireEmailVerification` on, so that login is refused `EMAIL_NOT_VERIFIED` at its first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired. + - **"Write a `sys_account` credential row directly against the store"** was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and the probe behind this report asks only whether *any* `sys_account` row exists, so writing one turns the report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end. + + The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — a lowercase address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door needs widening. It is an admission verdict and not a verification bypass, though, so the line scopes what follows from that: only under the default `invite_only` posture is the recovery mail-transport-free, and it tells the operator to close a widened posture back to `invite_only` before the invited person registers — otherwise the invited login is created, refused `EMAIL_NOT_VERIFIED` at first sign-in, and has silenced this report on the way past. On the `single` tenancy posture that account holder is then promoted to platform admin. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe. + + **Message text only — no admission semantics move.** Nothing widens, nothing narrows, no accept set changes, and the probe is untouched: this changes what an operator *reads*, not what the platform *admits*. The long form of the same three facts is on the self-hosting deployment page. +- 9f39897: fix(plugin-auth): the magic-link mail reads the recipient's own `sys_user.locale` (#15106) + + `sendMagicLink` was the last of the five auth mail sends still on the two-rung + #14319 ladder — the request's `Accept-Language`, then the deployment default. + #14762 put the recipient's stored `sys_user.locale` above both at the three + sends that hold a user row, and #14641 reached the invitation; the magic link + was fenced out because it is handed `{ email, url, token }` and no row, so the + column has to be read on the address rather than on an id. The visible cost was + one deployment answering the same person in two languages: a Chinese + password-reset mail and an English magic link, decided by whichever browser + happened to send the request. + + It now reads the column behind the existing placeholder-address refusal, in the + same shape #14641 gave the invitation send — one projected `findOne` on + `sys_user` under a system context, best-effort, and never a reason a send fails. + This completes the #14788 option-D ladder (`sys_user.locale` when set → the + request's `Accept-Language` → the deployment default) across the whole auth mail + surface: all five `sendTemplate` sites now answer per recipient. + + The request rung is kept rather than replaced. A magic link is requested BY its + recipient, so its `Accept-Language` is the recipient's own and remains a + legitimate second rung for an account that has stated no language; ruling D + inserts the column above the header, it does not remove the header. + + Two branches, because a magic link is also a sign-up: an address that carries a + row is written in that account's language, and an address with no row keeps + exactly the previous behaviour. The address is lowercased for the lookup — + better-auth applies no case transform to the magic-link request body, while + `findUserByEmail`, which `/magic-link/verify` resolves the very same link with, + matches on `email.toLowerCase()`, so the column is read for the row the link + will sign into. An address that resolves nothing lands on the rungs below, which + is the documented floor. +- 9e9f03a: A self-registration grant is refused, not silently redirected, when a permission-set row is malformed — and the fourteen dead `{ records }` / `{ data }` normalizer limbs behind that code are gone. + + `plugin-auth` carried fourteen array-or-envelope normalizer blocks of the shape `Array.isArray(x) ? x : x.records ?? []` (thirteen on a `records` limb, one on a `data` limb, four of them written as a guard clause rather than a ternary). All fourteen read the same concrete engine — the `ObjectQL` instance the kernel registers as the `objectql` / `data` service — which answers a bare array on every path, populated or empty. The envelope limb was unreachable code that read as a contract, so the next author writing a defensive normalizer here believed an envelope was possible. The limbs are removed, and the three local engine ports that declared `Promise` (`BootProbeEngine`, `DevAdminSeedProbeEngine`, `PhoneSmsTemplateEngine`) now declare the array they always returned. + + The user-visible change is in `settleSelfRegistrationGrant`, which carried the opposite defect. Its candidate filter dropped any permission-set row whose `id` was missing or blank, silently, before choosing which row to grant: + + - When the malformed row was the only one, the operator was told `no active sys_permission_set row named 'X' resolves` — false, since an active row named exactly that was present. That report is the only signal this path emits, and nothing retries it. + - When the malformed row was the **organization-scoped** one and a global row also carried the declared name, dropping it let the `organization_id == null` arm match instead, and the self-registrant was granted the **global** permission set their organization never declared — with a success log and no other trace. + + `active !== false` remains a selection predicate: a deactivated set still reports the ordinary "does not resolve". A malformed row is no longer a selection at all — the grant is refused and the report names the malformed row, so the ambiguity is surfaced instead of resolved by accident. A well-formed family grants exactly as before. + + **Upgrade note — one family now gets a refusal where it previously got a grant.** If a deployment's `sys_permission_set` already contains a row that is active and carries the declared name but whose `id` is missing or blank, self-registration grants against that name now stop and report, including the case where the malformed row is one nobody was relying on: a malformed **global** row sitting alongside a well-formed **organization-scoped** row used to be dropped silently, letting the org row be granted, and is now refused. This is deliberate — the old behaviour could not tell that family apart from the one where the silent drop granted the *wrong* set — and it is fully reversible without a code change: repair or delete the malformed row and the grant proceeds exactly as before. The refusal is loud and names the row, so it is visible rather than something to discover later; nothing is written while it stands. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [46803fa] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-messaging@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 8ab88d1d89..16a568c0d4 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Authentication & Identity Plugin for ObjectStack", "main": "dist/index.js", diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md index 696df98cbb..2d8ad00871 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,198 @@ # @objectstack/plugin-dev +## 17.4.0 + +### Patch Changes + +- 88a35c2: fix(plugin-dev): the i18n auto-detect resolves `translations` from `packages[]`, not only the flattened top level (#15232) + + `DevPlugin.init`'s 3b block read `options.stack.translations` and nothing else. + For a multi-package app under the ADR-0130 D4 option-B shape — where + `packages[]` carries each definition exactly once and the flattened top-level + copy is gone — that read returns `undefined`, the detection concludes "this app + declared no copy", and the boot continues. Nothing throws and nothing logs. + + What the developer gets instead is the wrong strings. `I18nServicePlugin` + (`@objectstack/service-i18n`) is never registered, so the `i18n` slot keeps the + core in-memory fallback: `os dev` serves message KEYS, or last release's copy, + for an app that declared real translations. It reads as "the translations are + broken", not as "a collection went missing", which is why it is a reader fix + rather than a footnote. + + The detection now reads the flattened top level FIRST and then each package + body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`, + ADR-0130 D4+D5) registers them: + + - **Every artifact the platform emits today answers bit-identically.** The + flattened level still answers first and short-circuits, so the `packages[]` + pass can only supply a declaration the top level did not have. This is the + reader half of the ruled order (readers first, emitter last, the artifact + additive throughout), so it lands with no change to what any command emits. + - **The caller's original expression is preserved, not re-expressed.** + `Array.isArray(t) && t.length > 0` still decides the top level, per package + body as well — re-expressing a gate as a resolved-and-counted traversal is + what silently changes the verdict for a stack that declares the key empty. + - **⛔ `stack.packages` is not iterated directly.** + `resolveArtifactPackageOrder` is the platform's one traversal and also the + GATE that parses each entry, so a second traversal would disagree with the + load path about which artifacts are loadable. An artifact with no `packages` + key is left entirely on the old path — the key's absence is checked before + the call, because D4's second branch would otherwise hand the caller's own + object back and read the same `translations` twice. + - **A malformed `packages` is refused, not skipped.** A non-array `packages`, + an entry inlined instead of wrapped under `manifest:`, or a duplicate package + id raises the same ADR-0112 envelope (`code` + `status: 422`) that + `ObjectQL.registerApp` raises for the same object later in the same boot. + + The decision — detection plus the locales it derives — is now one exported + function, `devI18nPluginOptions`, so the #15004 option-B acceptance pin + measures it by CALLING it rather than re-implementing the read. `DevPlugin` + keeps the dynamic import and its degradation: those are about the optional + package being installed, which is a different question from what the stack + declares. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [c1eafe6] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [fa85759] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [6615a02] +- Updated dependencies [9f39897] +- Updated dependencies [4ca358d] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [26144c2] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [e6279dc] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [46803fa] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [ebb5550] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [b8c82de] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-i18n@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/service-storage@17.4.0 + - @objectstack/service-realtime@17.4.0 + - @objectstack/account@17.4.0 + - @objectstack/setup@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 8e0dc430da..5a0bfb18e7 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index 2befc51292..ac62ec0dda 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,113 @@ # @objectstack/plugin-email +## 17.4.0 + +### Patch Changes + +- dfb7a0d: `plugin-email` strips read decorations with the shared list, not a blanket underscore sweep. + + `readEffectiveTemplate` — the layered read a `DELETE /meta/email_template/:name` runs to restore the packaged baseline an overlay was hiding — removed decorations with a module-local copy of `stripReadDecorations` that dropped **every** key beginning with `_`. The shared list it drifted from, `METADATA_READ_DECORATIONS` in `@objectstack/spec/kernel`, is exactly `['_diagnostics', '_draft']`, and its module header names the ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) as deliberately **not** a member: it is envelope state the write path legitimately carries, and the closed metadata schemas allowlist it so a served document keeps its provenance on re-parse. + + The private copy justified its sweep on the claim that `EmailTemplateDefinitionSchema` "declares no underscore key". That is false — `email-template.zod.ts` spreads `MetadataProtectionFields` into its `strictObject`, so every envelope key is declared and parses clean. The copy was removing keys the schema was deliberately widened to accept, and the list lives in `spec` precisely so a producer and its consumers cannot drift like this. + + The path now calls the shared helper, matching the other read-back-envelope consumers (the dataset query in `rest-server.ts`, the cold-boot flow bind in `service-automation`, `saveMetaItem`'s verbatim persist, and the route-level seed apply). Two behavioural consequences: + + - An underscore key that is neither a decoration nor declared is no longer swallowed before validation. The closed schemas exist to reject exactly that (protocol 17), and the rejection is now reported on the write's own response through the mutation projector, instead of the reset quietly succeeding against a body the schema would have refused. + - The ADR-0010 envelope survives the strip. It still does not reach `sys_email_template`: `upsertDeclaredEmailTemplate` projects the parsed template through `mapTemplateToRow`, a closed column list, and the object declares no underscore column — so no stored row changes shape. There is deliberately no second, envelope-stripping pass beside the shared one; spelling one would re-create the drift this fixes, one layer up. +- a4816a7: The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. + + `sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: + + - **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. + - **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. + + Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index e7eda7ae2e..33487a3b7f 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md index d21c0b02fb..f7721349bc 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/plugin-hono-server +## 17.4.0 + +### Minor Changes + +- 5f7fa1d: feat(hono-server): `GET /auth/me/localization` → `locale` is now the signed-in user's language — `sys_user.locale` when set, then the request's `Accept-Language`, then the deployment default (#14788) + + Maintainer ruling 2026-09-03 (option D on #14788): this endpoint is the ONE + read face for "what language is this user", now that `sys_user.locale` is a + user-stated preference (#13881 / #14787) and the never-produced + `SessionUser.language` is retired from the session contract + (`@objectstack/spec`, same release). + + What changed, for an authenticated caller: + + - `locale` resolves **the user's own `sys_user.locale`** first — read under a + system context by the caller's own id and accepted only when it passes the + column's OWN `locale_bcp47_shape` rule as the registry declares it (the + endpoint evaluates that rule; it carries no second locale parser). A + malformed, blank or unverifiable value falls through, it is never served. + - then **the request's `Accept-Language`** preference (`preferredLocaleFromHeader`, + the same parse REST and the runtime dispatcher feed `execCtx.locale` from); + - then **the deployment default** (`resolveLocalizationContext` — the + `localization.locale` settings cascade, floor `en-US`). + + Before, the resolver behind this endpoint assembled no localization at all, so + `locale` was `null` for every authenticated caller; it is now always a string + for an authenticated caller. The response shape is unchanged + (`{ authenticated, currency, locale, timezone }`), `currency` / `timezone` + are untouched, and the unauthenticated answer (`{ authenticated: false }`) is + unchanged. `resolveSignedInUserLocale` is exported for hosts that compose the + current-user endpoints directly. +- 6615a02: fix(plugin-hono-server): the current-user faces assemble their `ExecutionContext` through the shared assembler (#15747) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). + + `makeExecutionContextResolver` is exported from this package's index. Its declared return moves **from** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise` — in practice `any`, since the exported function carried no return annotation at all and the envelope it built was a hand-rolled object literal cast `as any` — **to** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise`. `any` is assignable to everything and admits every property read, so a consumer's code really can stop compiling. + + What this asks of a consumer holding the resolver directly (the serverless host path that composes it, cloud#924): narrow the `undefined` arm before reading the envelope — under `strictNullChecks` the resolver has always been able to answer `undefined` for a request with no session, and no caller was ever asked to handle it; and stop reading members `ExecutionContext` does not declare, since the receiver is no longer `any`. A consumer that only calls `registerCurrentUserEndpoints` sees no change. + + The envelope itself is now assembled by `assembleExecutionContext` (`@objectstack/core`) — the fail-closed entry every other HTTP transport already uses — instead of the hand-rolled literal, which omitted six fields of the closed entry set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. `principalKind` is `'human'` on these faces, the value the shared assembler derives for a session-backed principal; the other five are withheld on the record. A field added to `ExecutionContext` from now on fails to compile here until this face decides it. + + No runtime behaviour changes: `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` answer byte-identical bodies, pinned as goldens. + + + +### Patch Changes + +- fa85759: `GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null` + + The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 8fe69ada78..ac5d841ae4 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Standard Hono Server Adapter for ObjectStack Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md index 240f65cbe7..a4a10630ed 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,41 @@ # @objectstack/plugin-pinyin-search +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [a56baa2] +- Updated dependencies [4b3955e] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [65846bc] +- Updated dependencies [33388f9] +- Updated dependencies [fa125f3] +- Updated dependencies [7778115] +- Updated dependencies [088f761] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [25a3d91] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [26144c2] +- Updated dependencies [cc00df2] +- Updated dependencies [e6279dc] +- Updated dependencies [d4f9b2a] +- Updated dependencies [a727043] +- Updated dependencies [ec0a6e7] +- Updated dependencies [b398ad2] +- Updated dependencies [3d3f60e] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index 5a55d01c01..bb144ca23e 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-reports/CHANGELOG.md b/packages/plugins/plugin-reports/CHANGELOG.md index a245ee4bc6..617bfd7ca6 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,93 @@ # @objectstack/plugin-reports +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 606871f828..48803a90aa 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index a21770d6ce..ccc934ddf5 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,171 @@ # @objectstack/plugin-security +## 17.4.0 + +### Minor Changes + +- f9a3c32: feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + + `BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + + Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + + - **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. + - **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. + - **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + + What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. + +### Patch Changes + +- c64e65f: fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) + + `declaredPermissionSets` carried a docblock stating a short-circuit its code did + not have: + + > The `packages[]` pass only supplies a set where the top level had none — which + > is precisely the option-B artifact. + + The code pushed the flattened top level and then **every** package body + unconditionally, so on today's additive artifact (flattened level *and* + `packages[]` both present) every permission set was collected twice. Nothing + observable came of it — the sole caller is private and takes the first + `isDefault` set, which the flattened copy still supplied — so this corrects a + false written contract on a security-path reader, not a live defect. That + distinction is the point: the sentence was load-bearing, because it was the + stated reason the reader half was revertible on its own and safe to land before + the emitter half (#14512), and the next reader would have believed the mechanism + was there. + + ⚠️ Release-notes note: this supersedes one sentence of the #15226 entry in this same + unreleased batch — "The resolution now reads the flattened top level FIRST and then each + package body". That described #15226 accurately when it landed; after this change the + `packages[]` pass runs only where the top level named no default. The earlier entry is + left as written rather than retro-edited, so whoever compiles the notes collapses the two + deliberately instead of reading a contradiction. + + The reader now walks the discipline the docblock claims — start from the + expression this program replaced, `appDefaultPermissionSetName(config.permissions)`, + and consult `packages[]` only where it came back `undefined`. + + - **The condition is the resolved NAME, never the `permissions` container.** + Branching on the container re-creates the silent loss the reader program + exists to remove, one shape further along: a flattened level that carries + permission sets but marks none of them `isDefault` is legal today and + hand-authorable in any `objectstack.config.ts`, and a container-shaped + condition (`Array.isArray(flattened)`, with or without `&& length > 0`) shorts + it past the whole `packages[]` pass and answers `undefined` — nothing thrown, + nothing logged, every member of the app back down to the platform floor alone. + Reading the answer also retires the `[]`-is-truthy trap rather than patching + around it. + - **The package order is resolved BEFORE the top level is consulted.** + `resolveArtifactPackageOrder` refuses a malformed `packages` — not an array, + an entry inlined instead of wrapped under `manifest:`, a duplicate package id + — with an ADR-0112 envelope this reader does not catch, and that refusal must + not become conditional on whether the flattened level happened to name a + default first. An artifact is either loadable or refused; which level answered + is not part of that question. + - **No emitted artifact changes its answer.** Measured, not argued: 26 shapes — + the composed additive artifact, its option-B derivative, the collection-zoo + fixtures behind the #15004 acceptance pin, every config the unit suite drives, + the three malformed-`packages` refusals, and the hand-authored mixed shapes — + return byte-identical results before and after, with `@objectstack/plugin-security` + rebuilt and the change proven present in `dist/` on each leg. +- 06c762e: Remove seven dead `{ records }` union-normalizer limbs on engine `find()` results, and repair the one that was silently dropping instead of gapping. + + Six seams in this plugin normalized an engine read as `Array.isArray(x) ? x : x.records`. The envelope limb was unreachable: `ObjectQL.find` resolves a bare array of row objects, measured by booting a real engine over a real `SqlDriver` and driving each seam through the shipped function that owns it, rather than inferred from `IDataEngine.find`'s declared `Promise` (a declared type is not proof — this repo also has a `find()` that resolves an envelope). Each seam keeps its existing disposition for a non-array; only the dead limb is gone. + + The seventh is repaired in the opposite direction. `SecurityPlugin`'s `sys_permission_set` loader mapped three different facts onto one value: a read that succeeded on an empty catalog, a read that threw, and a read that resolved something it could not read all left as `[]`. On the enforcement plane that silently withdraws grants that exist while every request still looks normal, and it made `PermissionEvaluator`'s existing "db lookup failed" warning unreachable — so a transient database error and an empty catalog produced identical, undiagnosable 403s. The loader now lets the read fault propagate and refuses an unreadable result with `DATABASE_ERROR`. Enforcement is unchanged for every result the shipped engine produces; an envelope or a non-row element now refuses (fail-closed) where the old code read through it. An unanswered read still grants nothing; what changes is that it is now reported instead of silent. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 5539cdb0b9..792e46f5ee 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index b1ffd9d3cd..773dc90811 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/plugin-sharing +## 17.4.0 + +### Minor Changes + +- 9fa5775: feat(plugin-sharing): the `field` sharing recipient is enforced — expanded once per matched record + + `ShareRecipientType` gained `field` on the spec side (#14103, maintainer ruling + B): `sharedWith: { type: 'field', value: '' }` shares each + record the rule's criteria match with the user or users named by that column + on the record. This is the executor half (#15072): + + - `SharingRuleService` reads the named user-typed column on each matched + record. A `multiple: true` column shares with every user it names; a single- + user column with the one it names. **Fail-closed on empty**: a null or empty + column materialises no grant — never a match-all principal, never a fallback + to the record owner. `field` is the only recipient resolved per record; every + other kind (`user`, `team`, `position`, `business_unit`, + `unit_and_subordinates`) still expands once per rule. + - The grants re-materialise on the record's own write: the existing + `afterUpdate` hook has no changed-field gating, so an update that touches only + the recipient column re-runs the per-record reconcile, which revokes the + stale grant and materialises the new one. No second trigger was added. + - The whole-rule pass (`evaluateRule` — the background re-grant after an + unbounded bulk write, the `kernel:bootstrapped` backfill and the REST evaluate + endpoint) derives per-record (record, user) pairs for a `field` rule instead + of a matched-records × recipients product, so the rule is as correct after a + bulk write and a restart as it is inline. The recipient-axis revoke + (`revokeRuleGrantsForRetiredRecipients`) declines `field` rules — they have no + rule-wide recipient set to retire against. + - The declared-rule bootstrap seeds `field` rules (previously skipped with a + warning), the `sys_sharing_rule.recipient_type` select accepts `field`, and + `defineRule` refuses a `field` recipient whose `recipientId` is not a field + name (the same grammar the spec applies at parse). + - An active `field` rule whose column the object does not declare as user-typed + grants nobody and says so once per rule. + + There is no `manager` recipient: "the owner's manager" is a user field the + application stores on the record, named by a `field` recipient. + +### Patch Changes + +- a4816a7: The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. + + `sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: + + - **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. + - **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. + + Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. +- 4db3c61: `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + + `isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + + Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + + Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. +- 2e35765: The share-link REST surface now derives the tenancy posture before it resolves the caller, so an API key stamped with an organization its owner has left can no longer mint links into that organization. + + `resolveAuthzContext` gates every posture-conditional refusal on a `tenancyPosture` its **caller** supplies. `SharingServicePlugin`'s share-link door supplied none, so none of them ran: `organization_required` (`core/security/api-key.ts`), `organization_membership_ended` (`core/security/resolve-authz-context.ts`), and the session arm beside it that drops an `activeOrganizationId` claim no `sys_member` row backs. An API key's tenant is `sys_api_key.active_organization_id` copied verbatim — the caller's own stored claim, never vetted against current membership — so under a wall-enforcing posture (`isolated`, `group`) a key belonging to an ex-member was admitted carrying that organization, and `createLink` minted a capability token on a record inside it. The same door carried the session half: a browser session whose owner had been removed kept its organization claim until the session expired. + + Measured at the door, under `isolated`: the ex-member's key went from `200` / `201` with the link landing in the store to `401` / `401` with nothing landing; an organization-less key went from admitted to `401`; an ex-member's *session* now has its stale claim dropped and is refused by Layer 0 at `403` while staying signed in. A current member and an anonymous caller are unchanged in every wiring. + + A `tenancy` service that was **never registered** stays a supported composition and resolves quietly to "no posture" — behaviour on an embedding without `plugin-auth` is exactly what it was. A `tenancy` service that **was registered and failed to build** now raises `AuthzStoreUnavailableError`, which reaches the wire as `SERVICE_UNAVAILABLE` / 503 rather than being laundered into a `401`: admission was never decided, so it must not be answered. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [a56baa2] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e6279dc] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index f6bc396e68..995a4da41a 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index 6c08a73fec..37dd50b514 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,102 @@ # @objectstack/plugin-webhooks +## 17.4.0 + +### Patch Changes + +- a4816a7: The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. + + `sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: + + - **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. + - **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. + + Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/service-messaging@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index b450749bee..81f65bc654 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.", "type": "module", diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md index 7c015328e0..87c1b357f7 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/dogfood +## 0.0.44 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [dcad825] +- Updated dependencies [6136293] +- Updated dependencies [07f40e5] +- Updated dependencies [6573af9] +- Updated dependencies [54bb2f1] +- Updated dependencies [fd014b1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [dfb7a0d] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [9f39897] +- Updated dependencies [17f8604] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [9fa5775] +- Updated dependencies [d770b3e] +- Updated dependencies [a4816a7] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [a646120] +- Updated dependencies [ebb5550] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [2e35765] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [b8c82de] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] +- Updated dependencies [c550baf] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/objectql@17.4.0 + - @objectstack/service-analytics@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/plugin-email@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/mcp@17.4.0 + - @objectstack/plugin-sharing@17.4.0 + - @objectstack/plugin-webhooks@17.4.0 + - @objectstack/service-storage@17.4.0 + - @objectstack/verify@17.4.0 + - @objectstack/example-showcase@0.3.18 + - @objectstack/connector-mcp@17.4.0 + - @objectstack/connector-openapi@17.4.0 + - @objectstack/connector-rest@17.4.0 + - @objectstack/plugin-audit@17.4.0 + - @objectstack/service-messaging@17.4.0 + - @objectstack/example-crm@4.0.96 + - @objectstack/example-multi-package@0.0.3 + - @objectstack/metadata-core@17.4.0 + ## 0.0.43 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index f4175a4288..0093702bd7 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.43", + "version": "0.0.44", "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 e2c941d381..6394a5d015 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,76 @@ # @objectstack/downstream-contract +## 0.0.42 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 0.0.41 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index cab016a168..8fc05f97b7 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.41", + "version": "0.0.42", "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 66f2c4fb83..346314c1a4 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,19 @@ # @objectstack/http-conformance +## 0.1.4 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cf9bda4] +- Updated dependencies [cc00df2] +- Updated dependencies [d4f9b2a] +- Updated dependencies [a727043] + - @objectstack/core@17.4.0 + ## 0.1.3 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 6646106b27..882e78982f 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/http-conformance", - "version": "0.1.3", + "version": "0.1.4", "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 15a7f00c9c..794e9541ca 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,327 @@ # @objectstack/rest +## 17.4.0 + +### Minor Changes + +- 65846bc: fix(rest)!: an import ROW report spells a unique-constraint refusal `UNIQUE_VIOLATION` — the same wire code as the whole-request failure on the same route (#14723) + + + + **BREAKING** on the per-row results of the import runner + (`POST /api/v1/data/:object/import` and the import job): a row refused by the + engine's `DuplicateRecordError` envelope now reports `code: 'UNIQUE_VIOLATION'` + where it reported `'DUPLICATE_RECORD'`. Shipped as `minor` under the repo's + launch-window convention for breaking changes. Maintainer ruling 2026-09-03 on + #14723 (verbatim 「同意,然后执行契约复审」), adopting option A: one wire + spelling for a unique-constraint refusal on every route. + + **Why.** `toFailedResult` relayed the thrown error's own `code`, and the engine's + envelope carries the registered `DUPLICATE_RECORD` — while the whole-request + failure on the same import route answered `UNIQUE_VIOLATION` through + `mapDataError`. Two spellings of one condition on one route, which ADR-0112's + one-name-per-concept and the error-code ledger's header both forbid. The + duplication is removed, not declared: no ledger waiver is added. + + **What changes.** The import row derivation applies the whole-request arm's own + predicate — the registered code AND the class name `DuplicateRecordError`, + exported from `error-response.ts` as `isEngineDuplicateRecordEnvelope` and now + shared by the arm and the row report — and reports `UNIQUE_VIOLATION`. A + field-level finding still takes precedence (the envelope carries none), the + row's sentence is unchanged (the platform sentence, sanitised as before; no + driver text), and a producer that merely throws the registered + `DUPLICATE_RECORD` without being the engine's class keeps its own code. + + **What does NOT change.** The whole-request doors (single-record, bulk, import, + metadata, UI) already answered `UNIQUE_VIOLATION` and keep doing so; the arm's + logic is untouched beyond reading the shared predicate. The engine's thrown + identity stays `DUPLICATE_RECORD` in-process. This package's `error-response.ts` + docblock that disclosed the fork under the #14541 contract review now states + the converged rule. + + **Consumer note.** An import client that branched on a row's `code` reading + `DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it + already handles for the whole-request 409. Measured in-repo and in the sibling + repos (hotcrm, objectui, non-test sources): zero consumers branch on either + spelling of a row code. +- f5cc78b: fix(rest): the generic declared-status passthrough names its object on both error doors (#14725) + + **Response-body change on the published bulk / metadata / UI doors: one optional + key is added, `object`.** Nothing is removed, no status moves, and no `code` + value changes spelling. + + #14541 made the two REST error doors agree for every refusal a *bespoke* arm + classifies. They still disagreed for every refusal that reached the *generic* + declared-status passthrough, because the two copies of that one passthrough + differed by exactly one key: `classifyDataError`'s copy ends + `...(object ? { object } : {})` and `resolveErrorResponse`'s 4xx arm had no such + limb. Measured on `main` @ `a12b15e394` — one error object, both doors: + + | door | before | + |---|---| + | `mapDataError(err, 'duly_note')` (single-record `/data`) | `409 {"error":"…","code":"DUPLICATE_RECORD","object":"duly_note"}` | + | `sendThrownError(res, err, 'duly_note')` (bulk / metadata / UI) | `409 {"error":"…","code":"DUPLICATE_RECORD"}` | + + One refusal, two bodies, decided by which route caught it — the #14541 shape one + arm over. The bulk door now answers the first row too. + + It closes the same card's second residue with it. `recordNotFoundError` + (`@objectstack/core`) declares `code`, `status = 404` **and** `object`, so that + declared status carries a record-level not-found past the `RECORD_NOT_FOUND` arm + into this same generic passthrough on every route reporting through + `handleRouteError` / `sendThrownError`, while the single-record `/data` door + reached the generic arm in `classifyDataError` and shipped the name. Both doors + now agree for that producer in every combination of declared status and + door-supplied object. + + **Who sees the new key.** The name comes from the door's `object` *argument*, + never from `error.object`, so only a route that supplies one is widened. Of 35 + route call sites of this door, **9** pass an argument that can be a non-empty + object name — `POST /data/:object/batch`, `/createMany`, `/updateMany`, + `/deleteMany`, `POST /data/:object/:id/clone`, `POST /data/:object/import`, + `POST /data/:object/import/jobs`, `GET /data/:object/export`, and + `GET /ui/view/:object/:type`. The other 26 (21 passing nothing, 5 passing the + literal `''`) answer byte-identical bodies. `classifiedRefusalAnswer` — the + entry point the analytics dataset face and the record-share family re-dress — + calls this door with no `object` argument at all, so those envelopes' key sets + do not move. + + **What deliberately does not change.** The declared-**5xx** arm gains nothing: + its sibling `declaredServerFaultAnswer` names no object either, so the two doors + already agreed in that band and adding the limb there would *create* a + divergence, on top of putting a caller-supplied name into a body whose whole + rule is that a declared server fault says nothing beyond status and code. The + `RECORD_NOT_FOUND` arm's message-**text** limb + (`/^Record \S+ not found in \S+/i`) is not lifted above the passthrough either — + that boundary is #14541's, and it is now pinned behaviourally and positionally + rather than described. + + Consumer note: a client that key-counts or exact-matches an error body from a + bulk, import, export, clone or UI-view route will see `object` alongside `error` + and `code` where the equivalent single-record `/data` response has carried it all + along. A client that reads named fields is unaffected. +- 3d3f60e: An approval decision that lands while its flow run strands now says so in fields, not only in prose. + + `POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. + + The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: + + - **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. + - **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. + - **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. + + `@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. + +### Patch Changes + +- 4bc9821: An organization-scoped caller's own items now appear in the untyped metadata diagnostics sweep. + + `GET /api/v1/meta/diagnostics` has two arms. The `?type=` arm has stated the caller's organization since #13753; the untyped whole-registry sweep passed none, so the Studio governance summary reported clean tiles over a partition it never read — undercounting relative to the per-type drill-down screen you reach by clicking into it. A summary whose whole job is surfacing problems, and which structurally cannot see a class of them while its own drill-down can, issues a false all-clear. The untyped arm now forwards the caller's organization, so items that organization authored on the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`) are counted in `stats`, `total` and `scannedItems`. + + The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field, status code or contract surface. There is no single type to fold on for a whole-registry sweep, and folding on any one of them would suppress the organization for every type at once; instead `getMetaDiagnostics` reads each swept type through `getMetaItems`, which applies the `allowOrgOverride` read gate to its own request type, so every type is scoped on its own registry flag. A non-overridable type (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into the report. An anonymous or organization-less caller reads exactly what it read before, and the `stats` / `total` / `scannedTypes` arithmetic is unchanged in shape. +- a84e1ce: fix(rest): metadata label lookup honours the stack's declared `i18n.fallbackLocale` / `defaultLocale` instead of falling through to the `en` bundle (#14882) + + On a workspace whose labels are authored in `zh-CN` (`defaultLocale: 'zh-CN'`, + `fallbackLocale: 'zh-CN'`) and which ships only a courtesy `en` translation bundle, + `GET /api/v1/meta/object/:name`, the `/meta/:type` list, `GET /api/v1/meta` and the + public-form schema served the ENGLISH bundle labels to a `zh-CN` request (`Entry Sheet` + for an authored `填报单`, `KPI Assessment` for `KPI 考核管理`). The document translators walk + `requested locale → fallback chain → authored label` and default the chain to a literal + `['en']`; every REST seam passed none, so the declared fallback never reached the chain + and `en` was consulted before the authored label. + + Every metadata translation seam now passes `fallbackChain: [i18n.getFallbackLocale()]` — + the locale the i18n service's own `t()` falls back to, which `I18nServicePlugin` receives + from the stack config as `fallbackLocale || defaultLocale || 'en'`. For the workspace + above a `zh-CN` request now resolves `zh-CN → zh-CN → authored label` (the authored + Chinese labels), an `en` request still gets the `en` bundle, and a `zh-CN` bundle, when one + is shipped, still wins over the authored label. + + Feature-detected: an i18n service that does not declare a fallback (the method is + optional on `II18nService`; the core in-memory fallback has none) gets no chain and the + resolver's own default applies exactly as before. A stack declaring `defaultLocale: 'zh-CN'` + with `fallbackLocale: 'en'` is likewise unchanged — the declared `en` is honoured as it + reads. +- e13ede8: The admin "Used by" panel no longer clears a delete when the caller's own organization is using the item. + + `GET /api/v1/meta/:type/:name/references` backs that panel, whose empty case reads "Nothing in the metadata graph points at this item. Safe to delete." — advice given to an operator about to delete something. The door supplied no organization, so the reference sweep read the environment partition only: an organization-scoped `view` (or `dashboard`, `report`, `translation`, `email_template`) pointing straight at the object being deleted was invisible, and the panel issued a false clearance. It now passes the caller's organization, and those references are returned. + + The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field or contract surface. `req.params.type` is the reference TARGET, while the sweep spends the organization on the SOURCES it reads per type; `getMetaItems` applies the `allowOrgOverride` read gate to its own request type, so each source is scoped on its own registry flag. A non-overridable source (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into a delete clearance. An anonymous or organization-less caller reads exactly what it read before, and no status code or response shape moves. +- 7d7ca6c: `GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it. + + The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message: + + ``` + A the protocol cannot answer for this TARGET type (a `field`) + 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} + B the resolved kernel has no `findReferencesToMeta` at all + 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} + ``` + + A now answers in B's shape, carrying the producer's own sentence: + + ``` + 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} + ``` + + Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `.` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator. + + Two consequences for a caller: + + - `body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered. + - Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched. +- 53cbad9: The REST server's `api` configuration defaults now come from `RestApiConfigSchema` alone, instead of being restated in `packages/rest`. + + `RestServer.normalizeConfig` already parsed `config.api` against `RestApiConfigSchema` — and then discarded the result, rebuilding the block from a `??` chain over the raw input. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in a second package. They agreed key for key, and nothing measured that they would keep agreeing: changing a default in `@objectstack/spec` silently failed to propagate, because `api.enableUi ?? true` answers `true` for an absent key whatever the schema declares. Consuming the parse deletes the duplicate and makes the schema authoritative. + + The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default. + + One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat` — and it runs in two directions, not one. **Filled in:** those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination`. **Stripped:** inner keys the schema does not declare no longer survive, at either depth — an authored `documentation.logo`, a `documentation.contact.phone` or a `documentation.license.spdxId` inside the nested objects, a `responseFormat.extra` — where the `??` chain passed the authored object through by reference and kept every key on it. Both halves are the same parse: `documentation` / `responseFormat` (and their `contact` / `license`) are non-strict `z.object()`s, which fill in their `.default()`s and drop what they do not name — dropped silently, so this is a strip and not a new refusal. An object left unwritten stays absent, and nothing in the platform reads either key today: the normalized block is `private` to `RestServer`, which reads only scalars off it (`apiPath` / `basePath` / `version` in `getApiBasePath`, the `enable*` flags, `projectResolution`), and the repo has no other read site for either key — so no consumer observes either half. +- 9b459b7: The REST data doors' protocol requests are compiled against the declared contract again, so a field added to a data request schema reddens the build instead of going silently unsent. + + No runtime behaviour changes — every door assembles and forwards exactly the object it did before. What changes is what the compiler is allowed to see. `packages/rest/src/rest-server.ts` dispatched to the protocol through two erasing forms: `p.deleteData({ … } as any)` on the argument, and the stronger `(p as any).updateData({ … })` on the protocol object itself, which erases the check on *every* member — a misspelled method name would not have errored. Across the file that was 22 dispatch sites spanning `findData` / `getData` / `createData` / `updateData` / `deleteData`, their `*Many` and batch siblings, and `getUiView`. + + The casts were load-bearing rather than lazy: these call sites pass `environmentId` and `context`, and neither is a member of any data request schema. Neither should become one. `environmentId` is the transport routing key that selects the kernel *before* the protocol call and is already ruled out of the request shape; `context` is the server-derived execution context, and a caller-supplied `context` is a privilege escalation the ingress deletes unconditionally — putting it in the published request schema would re-open that door. Both are now declared on a typed envelope alongside the request type, so they stay server-side *and* compiled, and every other member of every literal is checked against the spec. + + One slot stays deliberately untyped and is now named rather than diffuse: `findData`'s `query` accepts both the declared AST and an undeclared wire dialect (`$top`, `$orderby`, `filters`, …) that the protocol normalizer folds. Three server-built literals speak that dialect; the erasure there is confined to the query slot alone, and the declared-versus-shipped mismatch is filed as its own question. +- 46803fa: fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711) + + `translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf. + + Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5. +- a727043: fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 + + Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an + organization its owner is no longer a member of **read and wrote that + organization's rows** on the wiring the open core actually builds. Not a silent + empty set — a GET that returned the other organization's records, and a POST + that landed a row read back from the store carrying that organization's id and + the ex-member as its creator. An organization-less key on the same deployment + read `200` with an empty set, which is the silent failure the wall exists to + replace. + + The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the + effective tenancy posture from a per-request kernel, and on the single-kernel + wiring there is no per-request kernel — so the posture was `undefined` on every + request, and both posture-conditional API-key refusals are gated on it: + `organization_required` in `api-key.ts` and `organization_membership_ended` in + `resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was + active the whole time; it compares against the caller's active organization, + and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim + — the holder's own stored claim. Enforcing the wall is what let the ex-member + through, because the one fact that would expose the ended membership was not an + input to the layer that could act on it. + + The single-kernel branch now derives the posture from a provider `rest-api-plugin` + wires to the lone local kernel's `tenancy` service, in the same shape as the + auth-service provider beside it. A host that registers no `tenancy` service is + unchanged and still admits: there is no wall on such a deployment, so there is + nothing for an organization-less key to be walled out of. A `tenancy` service + that was registered and **failed to build** is an outage and answers `503`, not + an admission — a posture that could not be read is not a posture that is absent. + + Refusals are now also said out loud on the server side, at `warn`, where each + one is decided: the key's row id (never the credential or its hash), the + principal, the organization and the reason. **The wire is unchanged** — both + refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the + body, so a holder of someone else's key learns nothing a plain 401 does not + already tell them. The operator, who previously had a key that was neither + revoked nor expired and a 401 that said nothing, now has a line to find. + + Behaviour that does not move: a current member's key on the same route still + returns its rows and still writes; a request with no credential still answers + 401; and an unknown, revoked or expired key is not a refusal at all, so a key + scanner produces no log volume. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-package@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 8c70d43272..8b3e1c8524 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol", "type": "module", diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 6804fde7eb..af84a4bf46 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,614 @@ # @objectstack/runtime +## 17.4.0 + +### Minor Changes + +- 6491463: `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + + **A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + + **The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + + **Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + + What to read instead, per case: + + - deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; + - asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + + Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + + New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. +- bca21f7: `POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. + + Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. + + `copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* + + - **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. + - **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. + + ⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. + + Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. +- 2c753fe: feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168) + + The previous release declared `AutomationContext.recordLoadDenied?: true` and + said so plainly: **declared, not yet populated on the flow face.** The + script/body face of both action doors emitted the signal, but + `dispatchFlowAction` handed `automation.execute` a context without it, so a + `runAs: 'system'` flow that guarded on the documented key was inert — never + `true`, never wrong, and indistinguishable from a flow whose caller could read + the row. + + **This release populates it, on both doors in one stroke** — REST + `POST /api/v1/actions/...` and the MCP `run_action` bridge: + + ```js + // a runAs:'system' flow, guarding before it acts on the subject row + if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ } + ``` + + - **The exact producer shape, unchanged.** The one shared producer + (`loadActionSubjectRecord` → `actionRecordLoadSignal`) already returns + `{ recordLoadDenied?: true }`, and the flow door now spreads it as a + **sibling of `record`** — never a key on the record, and **absent**, never + `false`, when nothing was refused. So a flow reads it exactly as a handler + does, `recordLoadDenied === true`. + - **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the + load OUTCOME (`subject`) instead of a bare `record`, and derives both the + record and the signal from it. A caller can no longer forward the row while + dropping the verdict that says the caller could not read it — the omission is + a compile error rather than a guard silently inert one door over, which is + the defect the handler-face signal was filed for. + - **Purely additive.** Nothing is refused that was not refused before, no + existing key changes value, and the `recordId` stamp is deliberately kept: + `record.id` still arrives exactly as it did, which is why the flag — and not + `record.id` — is the authorization predicate. Whether the automation engine + *acts* on the key (a flow-level refusal, a step condition) is a separate + decision and is deliberately not part of this change. + - **`@objectstack/spec` (docs only).** The contract's "not yet populated on the + flow face" sentence is retired; no type changes. +- cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + + `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. + + The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. + + What changed: + + - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. + - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. + - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. + + A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. + + `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. +- 8a12067: feat(runtime): the platform action route executes the declarative row-level `operation: 'update'` action (#14092) + + The spec half (#15077) made `operation: 'update'` + `patch` parse; nothing performed the + write, so an authored update action reached the action route with no handler and collected + the registry's loud not-registered answer. It now performs the write. + + `POST /api/v1/actions///` — and the MCP `run_action` bridge, through + the same shared executor — performs exactly ONE data-plane update of the current record: + + - **As the caller.** The write carries the caller's own `ExecutionContext`, never the + `isSystem`-elevated context a `type: 'script'` BODY runs under. There is no author body here + to trust, so the data plane's own gate is the only gate — the object's permissions, its hooks + and its validations fire exactly as for a user edit, and their refusals reach the caller with + their own `code` and `status`. This consumes the `runAs: 'user'` direction ruled on #14010; no + `runAs` key is added. + - **A caller who cannot read the row is refused before anything is written** (404 + `RECORD_NOT_FOUND`, the platform's one existence-non-disclosing envelope), by consuming the + caller-scope load's verdict rather than re-deriving it from the stamped `record.id` — the + #14143 class: a swallowed load must never become an implicit grant. + - **The write is `{ ...patch, ...collectedParams }`** — static values under the dialog's, so a + param of the same name wins. Nothing else from the action is merged, and the ADR-0104 D2 param + contract still bounds what the wire can add. + - **No current record ⇒ a located refusal**, never a silent no-op: no `recordId` on the route or + in the body, an action addressed at the object-less key, or an empty write bag each answer 400 + naming the action and the fix. + - **`undoable: true`** returns `undo: { type, objectName, recordId, undoData, redoData }` — the + prior values of exactly the fields written, `null` for a field the row did not carry, so the + existing Undo readers can restore. The three remaining `UndoableOperation` keys (`id`, + `timestamp`, `description`) stay the client's. + - `visible` is deliberately unread here: it is a per-record renderer predicate, and the + authorization is the point above. + + `operation` is read BEFORE `type` at every reader, so the HTTP door and the MCP bridge agree: + `isHeadlessInvokableAction` now accepts a declarative update (it has neither `target` nor `body` + by construction), `headlessActionTypeError` hands it no client-side-type prescription, and + `summarizeAction` reports `operation` and `requiresRecord: true`. + + Unchanged: a handler-less `type: 'script'` action WITHOUT `operation` still gets today's + not-registered 404 — the script path is not widened. +- b31ebfe: A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. + + An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated. + + Two independent halves: + + - **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`). + - **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing. + + **Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. + + **Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `Id`, or when its value equals what the bag carries under `recordId`, `Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too. + + ⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause. + + ⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change. + +### Patch Changes + +- e9fcd6b: feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) + + + + **BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescriptions are registered under protocol major + 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions; this card clears the `api/` directory against it. Measured with the + gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree + count falls **48 → 36**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | + | `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | + | `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | + | `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | + | `RouteDefinition.timeout` | `timeoutMs` | milliseconds | + | `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | + | `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | + | `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | + | `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | + + **Every value is unchanged** — only key names move. Every old spelling is a + `retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type + `never`) and fails the parse with the rename prescription rather than a bare + unrecognized-key error. + + ## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch + + Ruling B put this key explicitly in scope with its own BREAKING note: the + runtime-emitted measurements are read by humans and agents even though nobody + authors them. A consumer meets two retry-after values on one 429 — this + ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` + header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. + Spelled identically they read as one value in two places. + + **The HTTP `Retry-After` response header is a separate, unchanged surface.** Its + name is fixed outside this repo and nothing here touches it. Do not "fix" the + header to match the envelope, and do not read a surviving `retry-after` in + transport code as leftover work. + + ## Dispositions — one D2 conversion, five semantic entries + + Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only + one of the twelve that gets an ADR-0087 D2 conversion** + (`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack + collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered + metadata kind stored as a row, so the conversion chain has a seam that sees it. + `os migrate meta --from 17` lists the mechanical edits. + + The other eleven are wire payloads and construction arguments — a device-flow + response body, an error envelope, REST-plugin route registration, a batch-loader + config, a router registration, WebSocket client/server configuration. None is + ever a stack collection member or a `sys_metadata` row, so no conversion seam + runs on them and each carries a **semantic** entry instead: this is the + disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these + very shapes, and what ruling B prescribes for a runtime-emitted key. + + ## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror + + Attributed to RFC 8628 by the campaign card; the attribution fails against the + schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as + a set — `code` is not `device_code`, `verificationUrl` is not + `verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a + different type, an ISO-8601 instant where the RFC carries a relative lifetime). + A schema that already renames every RFC field it carries into house style cannot + claim the standard fixes the one name it left bare. Renamed rather than marked + deliberately: a wrongly marked key is exempted permanently and silently, while a + wrongly renamed one is visible. + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/runtime`'s policy chain (`computeCacheControl` now reads + `endpoint.cacheTtlSeconds`), the publish gate's issue path + (`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase + example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` + tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is + retargeted onto the live key — an alias must point at a key the schema really + accepts, and `cacheTtl` now accepts nothing. +- 98191d2: fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice + + `AppPlugin.start()` collects seed data from two locations — the top-level + `data` field, then the legacy `manifest.data` for backward compatibility. The + legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a + FLAT bundle — manifest fields written directly on the bundle rather than nested + under `manifest:`, a shape `AppPlugin` supports by design and this repo's own + tests construct — it re-read the very array the top-level read had just + contributed. Every dataset landed in the collection twice. + + `mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies + reached the shared `seed-datasets` registry, the inline boot seed, and every + later per-org replay. For an `upsert` dataset with an `externalId` the second + pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset + it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls + for one record. + + The legacy read now carries the same reference guard its sibling collector has + always carried: `loadTranslations()` performs the identical two-location read + and skips the legacy half when `manifest.translations` IS the array the top + level already contributed. That asymmetry between the two collectors was the + whole defect, so the repair is the sibling's guard rather than a third spelling + of the same idea. + + ⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a + genuinely different array from its top-level `data` still contributes both, and + a bundle that nests its manifest is unaffected either way. Nothing is added to + or removed from any published surface. +- f1a1028: fix(runtime): a multi-package artifact's collections are read from `packages[]`, not only from the flattened top level + + A release artifact composed with `manifest: 'preserve'` carries every + definition twice — flattened at its top level, and again under + `packages[]` (ADR-0130 D4). Only two readers had ever learned the second + half: `ObjectQLPlugin`'s manifest service and the metadata artifact door. + Every other reader said `artifact.` and nothing else, so an + artifact that carried a collection under `packages[]` alone reached them + EMPTY — and nothing threw. The app booted clean having lost its + declarative actions, its scheduled jobs, its seed data, its object routing + or its default permission set. + + `resolveArtifactCollections` — new, and PACKAGE-PRIVATE to + `@objectstack/runtime` — is now the one way this package reads a top-level + collection out of an artifact in either shape. It takes the artifact's own + top-level value first and whole, then adds from each package body — in + `resolveArtifactPackageOrder`'s dependency order — the items the top level + did not already claim. A bundle that carries no `packages[]` is returned + unchanged, by identity: every single-package artifact and every + `defineStack()` config reads exactly as before. Nothing is added to any + package's published surface: `@objectstack/core` is untouched by this + change, and the new module is not named by + `packages/runtime/src/index.ts`. + + Where one collection key is spelled two ways inside one artifact — + `functions` is `z.union([z.record(…), z.array(…)])`, so two packages can + each be schema-valid and disagree — the read is REFUSED with an ADR-0112 + envelope (`MIXED_ARTIFACT_COLLECTION_SHAPE`, 422) rather than one spelling + being skipped. `composeStacks` already refuses the same mix at compose + time for the same reason. + + Taught to use it, in `@objectstack/runtime`: + + - `AppPlugin` — declared datasources and their auto-connect, the + `datasourceMapping` object routing, the objects handed to the connection + service and to the hot-reload seeder, scheduled jobs, seed datasets, + translation bundles, and the ADR-0057 security collections + (`positions` / `permissions` / `capabilities` / `sharingRules`). A job + handler's `ctx.bundle` is now the resolved view too, so + `ctx.bundle.objects` answers on a multi-package artifact. + - `collectBundleActions`, `collectBundleHooks` and + `collectBundleFunctionEntries` — including the object-EMBEDDED actions + that ride on `objects[]` and disappeared with it. + - `mergeRuntimeModule` — the declaration half. The sibling ESM module + re-supplies every callable regardless of shape, so `functions` was not + absent: a function declared `effect: 'writes'` simply came back as a bare + callable and defaulted to `'pure'`. It registered, it ran, and its writes + were counted as none. + - `createStandaloneStack`'s surfaced `requires` / `objects` / + `permissions` / `positions`, which drive the CLI's tier resolution, its + engine and storage-driver auto-registration, and the ADR-0056 D7 default + permission set. + - `resolve-project-database`'s project-database tier, which opens the + artifact itself and runs before any stack exists (`os dev`, `os start`, + `os db clean`). Without this a multi-package project silently fell + through to the unified default database instead of the datasource it + declared. + + Nothing about what the platform EMITS changes: `composeStacks` and the + artifact format are untouched, and the flattened top level is still + written. This is the reader half of the option-B program (#14512). +- c1eafe6: The `/auth` dispatcher domain no longer claims sibling namespaces such as `/authx` and `/authentication/foo`. + + `createAuthDomain` registered `{ prefix: '/auth' }` without a `match`, and `DomainRoute.match` defaults to `'prefix'` — a bare `path.startsWith('/auth')` with no segment boundary. Every path whose first segment merely *began* with the five characters `auth` was therefore claimed by the auth domain and forwarded to the auth service, instead of falling through to the dispatcher's `ROUTE_NOT_FOUND`. Measured on a real boot (a real kernel with `AuthPlugin`, served through `createHonoApp({ kernel, prefix: '/api/v1' })`), `GET /api/v1/authx`, `/api/v1/authx/foo` and `/api/v1/authentication/foo` were all claimed; `/api/v1/aut/foo` and `/api/v1/zzz/foo` were not, which is what located the boundary at the `auth` prefix. + + The route now declares `match: 'segment'` — the spelling the registry's other boundary-correct domains (`/keys`, `/mcp`, `/mcp/skill`) already use. It claims `/auth` exactly and everything under `/auth/`, and nothing else. + + **What does not change.** `/auth/me/permissions` and `/auth/me/localization` still reach `dispatch()`. Neither is a better-auth endpoint, so the adapter's `/auth/*` mount disclaims them and they arrive at this domain; `'segment'` keeps claiming them, which the accompanying test pins as an overshoot control alongside the three narrowed rows. + + **If you mounted a namespace under `/authx`, `/authentication`, or any other first segment starting with `auth`,** it was previously shadowed by the auth domain and answered by the auth service. It is now reachable — register a domain handler for it, or expect `ROUTE_NOT_FOUND`. +- da1cffb: An environment-scoped URL now reaches a dispatcher domain instead of answering 404. + + `HttpDispatcher.dispatch()` reads the scoped-URL prefix in three places — the environment-id hint parser, the OAuth-on-MCP gate, and the scope strip that lets `DomainHandlerRegistry` match the remainder. Only the first had been moved to the ADR-0006 `/environments/` spelling; the other two still matched the retired `/projects/` one. The strip therefore never fired on a real scoped URL, and since the registry matches from the head of the path, every environment-scoped request arriving through the `@objectstack/hono` catch-all — the entry cloud hosts mount, and the only one that hands `dispatch()` a still-scoped path — matched no domain at all: + + ``` + GET /api/v1/environments//data/task -> 404 ROUTE_NOT_FOUND (now: reaches /data) + GET /api/v1/environments//health -> 404 ROUTE_NOT_FOUND (now: 200) + GET /api/v1/data/task (control) -> reaches /data, unchanged + ``` + + The dispatcher-plugin's own scoped mounts were never affected: they pass a pre-stripped subpath (`${prefix}/environments/:environmentId/automation` dispatches the literal `/automation`), which is why the standalone server showed nothing. + + The OAuth 2.1 gate moved with it. An access token is honoured only on the MCP surface, and that test runs against the still-scoped path — so `/api/v1/environments//mcp` would have reached the MCP domain with its token refused had the strip been repaired alone. + + **If you still emit the old spelling**: replace `/api/v1/projects/:projectId/...` with `/api/v1/environments/:environmentId/...`, as `content/docs/api/environment-routing.mdx` has instructed since ADR-0006 D2. That prefix is no longer stripped, and it was never a working alias in the first place: nothing parses `/projects/`, so stripping it discarded the only place the request named an environment and served it from the host default instead. ADR-0006 D2 retired `project` on the API surface with no aliases, so the repair is one spelling in all three readings rather than a two-prefix alternation. +- 4db3c61: `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + + `isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + + Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + + Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. +- e9fcd6b: fix(runtime): `AppPlugin` threads the authored `job.timeoutMs` to the scheduler as `timeoutMs` (#14478) + + The declarative job door passes `{ retryPolicy, timeoutMs }` to + `IJobService.schedule`, following the `@objectstack/spec` rename of the + authored key and of the `JobScheduleOptions` contract key that carries it. Same + value, same per-attempt limit. +- 401e50a: The runtime dispatcher door no longer admits a request on a tenancy posture it could not read. + + `resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants. + + The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text: + + - **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders. + - **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent. + + Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request. +- ee32e1c: fix(runtime): a sandboxed hook body no longer launders an untouched `readonly` field onto the row + + A `beforeUpdate`/`beforeInsert` body running in the sandbox made the engine believe it had + written payload keys it never named, and a `readonly` field the caller supplied then survived + the readonly strip and landed. Measured end to end: with `locked_at` declared + `{ type: 'datetime', readonly: true }` and seeded to `2020-01-01`, a caller sending + `locked_at: new Date('2099-12-31…')` alongside a body whose whole source is + `ctx.input.touched_by = 'hook'` stored the caller's 2099 value — while the same object's + readonly `text` field was correctly stripped in the same request. + + The cause was a comparison of unlike things. The write-back decides whether a body wrote + *through* an object-valued key by comparing the host payload value against the VM's exit dump, + and the dump has been through `JSON.stringify`/`JSON.parse` while the host value has not. A + `Date` therefore never compared equal to its own ISO projection, took the documented + "cannot prove equal ⇒ carry it back" path, and was re-asserted onto the proxy that records + which keys a hook wrote. The class was every object-valued value a JSON round-trip cannot + prove equal — an object carrying an `undefined` member included, a `Date` being only its most + reachable member. + + The entry value is now normalised through the same round-trip the VM saw before it is + compared. The same change ends a fidelity loss on non-readonly fields: an untouched key is no + longer carried at all, so a host `Date` is no longer replaced by an ISO string on its way to + the driver. + + Fail-open behaviour is unchanged for values the round-trip genuinely cannot evaluate: a cyclic + or bigint-bearing payload value is still reported as changed and still carried, per key. +- 4b0508e: docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) + + Comment and prose only. No predicate, no assertion and no served shape changes; + every pin behind the `writable` verdict stays green as written. + + The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own + explanation, and this corrects it at every site that repeated it. The claim was + that the scope-less booted row `isWritablePackage` answers `false` for is *the + `type: module` sub-package a multi-package artifact carries*. It is not, and it + never was: + + - `defineStack` parses every `packages[]` entry through `ManifestSchema` + (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is + `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of + a compiled artifact is ever scope-less — `dist/objectstack.json` and both + served rows carry `scope: "project"`. + - A genuinely scope-less row arises only where a manifest reaches the registry + **without** that parse, because `installPackage` stores a key-by-key copy that + applies no defaults: a marketplace install / offline file import + (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, + read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to + `installPackage`) for the **database base, writable** half. + + Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an + unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` + of the same unparsed body yields a record with no `scope` key at all. + + What stays, because it is true and load-bearing: a scope-less **booted** package + is read-only while a scope-less **database base** is writable, and only + `engine.manifests` tells them apart — which is why the server owns the verdict. +- 4c0b22b: The package-publish door's route-level seed apply can consume the platform's own read-back envelope again. + + `POST /packages/:id/publish-drafts` reads each just-published `seed` body back through `protocol.getMetaItem` before handing it to the seed loader. That read exits through `decorateMetadataItem`, which stamps `_diagnostics` on every body whose metadata type has a registered schema — `seed` has one — and `SeedSchema` has been closed since protocol 17. So the door refused the document it had just been served: `unrecognized_keys: ["_diagnostics"]`, minted as a 422 and delivered on a **200** as `seedApplied.error`. Zero rows loaded, and the author was told their seed body failed spec validation when nothing about it was wrong. + + The read-back is now passed through `stripReadDecorations` at the unwrap — the same helper, for the same reason, that the dataset query, the cold-boot flow bind and `saveMetaItem`'s verbatim persist already call. `METADATA_READ_DECORATIONS` is the declared list of keys the read path derives from a document and attaches to the *response*, so removing them restores the document the author actually wrote. + + Nothing is widened to accept them: `SeedLoaderRequestSchema` stays closed, and the publish response keeps its declared shape. The strip is deliberately **not** a blanket `startsWith('_')` sweep — the ADR-0010 protection envelope (`_packageId`, `_provenance`, …) is not a read decoration, and the metadata schemas allowlist it precisely so a served document keeps its provenance when it is parsed again. + + Only protocols that do not self-apply seeds inside `publishPackageDrafts` reach this path; the shipping protocol self-applies and was never affected. +- 8744de9: The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row. + + `applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`". + + That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type). + + The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries. + + One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves. +- f7db8f4: fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) + + `validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. + + ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. + + Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. + + Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. + + `@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one). +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [65846bc] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [e1d4f9e] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [9f39897] +- Updated dependencies [4ca358d] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [61821e5] +- Updated dependencies [26144c2] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [46803fa] +- Updated dependencies [618f70d] +- Updated dependencies [33e939f] +- Updated dependencies [4b0508e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [7d711c9] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/metadata-protocol@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/driver-turso@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-i18n@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/service-cluster@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b34067c451..4907c97aee 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack Core Runtime & Query Engine", "type": "module", diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md index f9f2b0755c..8b049eb7eb 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 734ec9c716..06edc5cf09 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", "main": "dist/index.js", diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index 6f4d7ca671..58d83de784 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,220 @@ # Changelog — @objectstack/service-analytics +## 17.4.0 + +### Minor Changes + +- 6136293: A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098) + + The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed. + + What changed: + + - **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape. + - **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`. + - **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift. + + Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch). + + Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration: + + - **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made. + - **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite. + - **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column. + - **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional. + - **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined. + + Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`. +- 07f40e5: A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) + + `POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource): + + ```json + {"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}], + "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]} + ``` + + `min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch. + + What changed: + + - **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies. + - **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached. + - **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path. + - **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it. + + `AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string. +- 6573af9: A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type + + `POST /api/v1/analytics/dataset/query` has two producers of one response: the engine, and — when the request renders the as-if-published world over a pending seed draft (ADR-0037 P3) — `evaluateAnalyticsQueryOverRows`. The second one coerced every aggregate operand with `Number()` and dropped the non-finite ones, so a `min` / `max` over a non-numeric field answered `0`. Measured on one dataset and one row set, with two services differing only in whether a pending seed draft exists: + + ``` + live {"category":"travel","latest_spend":"2026-05-12"} + preview {"category":"travel","latest_spend":0} + ``` + + That is not a mislabelled column: it is a different, wrong answer to the same query, with no refusal and no warning, on the path an author is looking at *while* authoring the dataset. + + What changed, per member of the closed `AggregationFunction` vocabulary: + + - **`min` / `max` return the winning operand in its own type.** Ordering goes through this file's shared `compare` — so an ISO date orders as a date, a BSON `Date` orders as its instant against wire text, and text orders the way `MIN(text_col)` does on a SQL face — with a numeric arm so a numeric column written as text (`'800'`) still orders numerically. `cross-object-rebucket.ts` settled the identical question for the recombination path: the value these two pick is a value OF the column, so it must come back in the shape the row carried. + - **A group whose operand is null throughout answers `null`, not `0`** — `emptyGroupValueFor` (`@objectstack/spec/data`) rules `min` / `max` over nothing unanswerable, and `0` reads as a measurement nobody made. + - **`count_distinct` answers a cardinality again.** Its arm was spelled `countDistinct`, a word no producer mints (`dataset-compiler` copies the spec's `count_distinct` through), so it was unreachable and the measure fell to the numeric default — answering a row count under the author's `count_distinct` name (measured: `3` where the live path says `2`). + - **`count` stays a row count and `sum` / `avg` stay arithmetic.** Counting dates is still counting. + - **`sum` / `avg` over a TEMPORAL operand is deliberately unchanged.** There is no defined answer — the SQL faces do not agree on one either — and refusing an incoherent aggregate/field-type pair is an open decision, not this fix's to invent. + - **A dimension column is typed from the cube dimension**, the same expression both live producers use (`d?.type || 'string'`), so a `date` dataset dimension is `time` on the preview path as it already was on the live one. A MEASURE column keeps the `number` every producer mints; correcting that is the ADR-0021 descriptor pass's one rule, not a second copy here. + + Derived measures are untouched: `computeDerived` still coerces with `Number()` and answers `null` for a non-finite operand — but a derived ratio over a temporal `min` / `max` now sees a date instead of the spurious `0`, so it answers `null` on the preview path exactly as it already did on the live one. +- 54bb2f1: The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) + + `$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. + + On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. + + What changed: + + - **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. + - **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. + - **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. + + `$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. +- a646120: The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer. + + `compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant. + +### Patch Changes + +- dcad825: Analytics `$icontains` no longer compiles a `translate()` call on the `sqlite` and `mysql` dialects. On **SQLite** that function does not exist and the statement failed to parse — measured on the engine, not inferred. On **MySQL** the same construct was emitted and its arm is repaired the same way, but nothing was ever executed there: the MySQL arm is asserted as emitted TEXT only, on this face and on `driver-sql`'s alike, so no MySQL parse failure is claimed as measured. + + `$icontains` folds ASCII case on both sides of the comparison (#4706 Q1 = A). All three of this package's SQL compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo of that statement — spelled that fold as `translate(col, 'ABC…', 'abc…')` on all four dialect values a compiler can see: `sqlite`, `mysql`, `postgres` and `unknown`, onto which `normalizeSqlDialect` maps everything else, an unset hook and `'oracle'` included. `translate()` is PostgreSQL/Oracle; SQLite has none. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate` — so this was not a filter that returned the wrong rows, it was a statement the engine refused. On a SQLite datasource, an analytics `where` carrying `$icontains` and an **RLS read scope** carrying it were both unusable. + + The fold is now chosen per dialect, on the same construct table the case-exact text family already used, reached through one `fold` flag: + + - **SQLite** — `lower(col) GLOB lower(?)`. SQLite's `lower()` is ASCII-only (measured: `lower('CAFÉ')` is `cafÉ`), so this is the ruled fold rather than an approximation of it, and it runs. + - **PostgreSQL** and the `unknown` residue — `translate()`, byte-for-byte what those two arms emitted before. Measured set for that word: this package's own suite pins six cells verbatim — `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {dialect unset, 'postgres'}` for `{name: {$icontains: 'acme'}}`, full emitted SQL and the exact bound params — and the round-1 contract review widened it to **2,721 cells** (2,720 = `{undefined, 'postgres', 'unknown', 'oracle'} × 5 compiler paths × 8 filter shapes × 17 comparands`, plus the bare `{dialect: undefined}` cell), emitted at the merge-base blobs (all five hash-verified) and again at this head: **0 changed cells, 0 error cells**. Outside that set nothing is claimed — no PostgreSQL server was contacted, and on `sqlite` and `mysql` the bytes deliberately changed (340 of 680 cells each, all inside the four `$icontains` shapes). + - **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator; the review measured the two faces byte-equal on 60 of 60 MySQL cells. Asserted as text only — no MySQL server is provisionable in the container that wrote this, so that cell is a declared skip, not a claimed pass. + + ⚠️ Carve-out, stated because it is the surviving half of the defect and not an aside: an `unknown` dialect that is really SQLite is **not** fixed by this change. The residue is reached by four constructions the round-1 contract review drove rather than reasoned — a `SqlDriver` given a **class** client or an unrecognised spelling (`'libsql'`), a host hook answering knex's own `'sqlite3'`, a directly-constructed public `AnalyticsService` with the optional `sqlDialect` omitted, and a `data` service without `getDriverForObject`. For each of them `translate()` still reaches the engine and still fails to parse, on the `where` path, the read scope and the echo alike. No in-repo SQLite driver lands there — `SqliteWasmDriver` and `TursoDriver` both answer `"sqlite"`, measured — so this is an embedder-composition population, not a shipped-driver one. Tracked as #16028. + + `$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect the compilers accept — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. Measured set for that word: 510 cells (six dialect names — the four values above plus `'oracle'` and an unset hook, which both normalize to `unknown` — × 5 compiler paths × 17 comparands), 0 of them identical between the two families and no `$contains` cell carrying a fold. + + ⚠️ One deliberate divergence from `driver-sql`, recorded here rather than only in this package's source: `driver-sql`'s own `unknown` arm folds with `LOWER()`, this one keeps `translate()`. Each face keeps the residue it already had, and adopting `LOWER()` here would silently restore on PostgreSQL the Unicode fold #4706 Q1 = A rules out. The pointer exists on this side only; `driver-sql` carries no cross-reference back. +- fd014b1: Analytics `$icontains` no longer compiles a `translate()` call on the `unknown` dialect arm, so a datasource whose dialect nothing answered — which includes SQLite — gets a statement its engine can parse. **Graded `patch`:** no exported type, signature or option changes; the package's own contract for the operator (#4706 Q1 = A, an ASCII-only fold on both sides) is unchanged, and this repairs an arm that could not run rather than adding or retiring behaviour. What moves is emitted SQL text on one arm, measured and enumerated below. + + `normalizeSqlDialect` maps **everything it cannot name** onto `unknown`: an unset `sqlDialect` hook, `'oracle'`, `'libsql'`, a `SqlDriver` handed a knex Client **class** rather than a spelling. #15780 left that arm folding with `translate()` and recorded it as "never broken", which was true of the dialects the arm was *pictured* as — mssql and oracle, which have `translate()` — and false of the ones actually routed there. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`, so on all three of this package's compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo — the statement failed to **parse**. It reached the client as a 500, not an ADR-0112 refusal. One of the four constructions that land there is a directly-constructed public `AnalyticsService` with its **optional** `sqlDialect` omitted: leaving out an optional field turned a documented operator into a 500. + + The `unknown` arm now folds with one nested `REPLACE` per ASCII letter — the chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two arms cannot fold different alphabets. `REPLACE` is the one string function every SQL dialect has, and the domain is the same 26-letter constant, so the fold is ASCII-only **by construction**: + + - **PostgreSQL / Oracle-like** — same result set as `translate()`. The chain equals the simultaneous `A`-`Z` map because no step can feed a later one: every replacement writes a lower-case letter and every later step matches an upper-case one. Measured on the engine over **every ASCII code point** plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly. + - **SQLite-like** — it runs. Executed over the shared `FILTER_TEXT_CASES` `$icontains` rows through all three compilers on sql.js: the same row sets the `sqlite` arm is required to answer, including the `CAFÉ`/`café` pair that separates an ASCII fold from a Unicode one. + - ⛔ **Not `LOWER()`**, which is what `driver-sql`'s own `unknown` arm folds with. `LOWER()` follows the collation, so adopting it would trade this parse failure for **silently wrong rows** on PostgreSQL — the Unicode fold #4706 Q1 = A rules out. ⚠️ Measuring `LOWER()` in this container proves nothing about that: SQLite's `lower()` is ASCII-only and passes the same fixture, which is exactly the trap of letting a green SQLite reading stand in for a PostgreSQL one. No PostgreSQL server was contacted. + + **Which cells moved.** The emitted SQL and bound params of `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands` = **1,785 cells**, generated at this head and again with the emitter reverted to its merge-base blob (both legs hash-verified on disk and rebuilt, the marker's presence and absence checked in `dist/`): **204 moved, 1,581 byte-identical, 0 error cells either side.** Every moved cell is `$icontains` on one of the four dialect inputs that normalize to `unknown` (51 each = 17 comparands × 3 compilers). **0 of the 204 changed their bound params** — only the fold's spelling moved, never the escaping or the `ESCAPE` binding. Nothing moved on `postgres`, `sqlite` or `mysql`, and no case-exact operator moved on any dialect input. + + ⚠️ **The cost, stated rather than left to be found:** the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run. + + ⚠️ **The residue that remains**, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is `LIKE`, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the `'mariadb'` spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the **same** residue this arm's case-exact neighbour already carries and names — not a new one — and on those engines `translate()` did not run at all, so nothing that answered correctly before stops answering. + + `SqliteWasmDriver.dialectName` gains a direct pin. It answers `"sqlite"` only through an `isSqlite` override (the base class string-matches `config.client`, and this transport passes a class), that override had **0 direct test hits**, and it is the sole reason no in-repo SQLite driver reaches the arm above. The new pin includes the control: the base class answers `'unknown'` for that very config. +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- d770b3e: Analytics: a draft-preview dataset response now describes its columns like the live one + + `AnalyticsService.queryDataset`'s ADR-0037 P3 draft-preview branch returned before the + ADR-0021 result-column enrichment ever ran, so a dataset queried while the base object had a + pending seed draft came back with none of its column metadata: `fields[].label`, `format`, + `currency`, `percentScale`, `builtinAggregate`, and the temporal `type` correction were all + absent, on measure and dimension columns alike. A renderer then fell back to humanizing the + raw measure name and guessing a percent scale from magnitude — so the same dataset in the + same widget described its columns differently depending only on whether a pending seed draft + existed, which is the surface an author is looking at while authoring the dataset. + + Every one of those keys is read off the authored dataset and the source object's field + metadata, never off the rows, so the enrichment is now one method both paths call. Dimension + VALUE label resolution (resolving a lookup id to a display name) stays skipped on the preview + path deliberately: drafted seed rows reference lookups by name, so there is no id to resolve. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 1f5cab0c7d..9423f7b794 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)", "type": "module", diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md index 34ee3022a9..6799d49d4b 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,435 @@ # @objectstack/service-automation +## 17.4.0 + +### Minor Changes + +- 954cb0b: feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention; the level also follows the 2026-09-04 bump ruling — + this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an + additive widening is at least `minor`). No ADR-0087 conversion: no authorable key + is renamed or retired, and the shape this refuses was never a shape any surface + offered. + + The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be + a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size` + …) is finally reachable from metadata — until now CEL was only ever asked for a + boolean. The spec half landed the contract (PR #15113); this is the half that + makes it do something. + + ```yaml + # before: written into the variable verbatim, and rendered by `notify` as + # {"dialect":"cel","source":"joinNonEmpty(...)"} + # now: evaluated — digest is "Renewal due\nInvoice overdue" + assignments: + digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' } + ``` + + - **Evaluated at run time.** The built-in `assignment` executor evaluates a + `value`-role envelope with the expression engine and assigns the result, in the + same CEL scope a flow predicate is evaluated in (one shared scope builder, so a + predicate and a value expression cannot disagree about what `rows` means). A + plain string keeps today's `{token}` interpolation, and every other literal is + still assigned as data. + - **Refused at three doors.** A malformed envelope now stops the flow registering + (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as + a located `error` finding naming the node and the author's own variable — + `config.assignments.digest` — both at `objectstack validate` and at the runtime + publish gate a Studio / REST / MCP flow write goes through + (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes: + ['flow']`). Malformed is a composition, not a fixed list: whatever + `AssignmentValueSchema` refuses in the envelope's shape — among them a missing, + empty or non-string `source`, a dialect other than `cel`, a non-object `meta` — + and then CEL that does not parse. All three doors derive that set from the same + two published validators, so none refuses a shape the executor would have run, + and a registered flow never faults for a shape those validators judge malformed. + Two shapes sit outside what either validator can judge — an `ast`-only envelope + and a whitespace-only `source` (it passes `min(1)` and reads as "not authored" + to the validator, while the CEL engine parses it untrimmed) — and those fault + loudly at run time rather than assigning a value. Both are pinned and tracked in + #15430. + - **Only the canonical map.** The ledger declares `assignment.assignments.*` and + nothing else, so the two legacy shapes the executor still normalizes — the + `assignments: [{ variable, value }]` array and the bare `{ : }` + config — keep every meaning they had, envelope-shaped values included. + `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the + array form: refusing it would break flows that register today, and that refusal + is a maintainer ruling rather than a lane's call (#15137 ask 3). + + **What changes silently, and how far it reaches.** A flow that today authors an + envelope-shaped object *as data* in the canonical `assignments` map now evaluates + it — no error on either side, a different value. The discriminator is the spec's + own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`, + in the declared map only. Data that names no `dialect`, names a non-string one, + nests the envelope one level down, or sits in either legacy shape is untouched + and byte-identical. The remaining overlap — a well-formed + `{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly + the spelling the ruling reinterprets; every near-miss the two validators can + judge now refuses loudly at registration instead of changing value in silence. +- d30ccb9: A contained per-iteration failure is now visible at run level, attributed to its iteration, and bound to its row. + + `loop { body: [ try_catch { try, catch } ] }` is the containment spelling for a per-iteration failure that must not end the sweep (there is deliberately no `loop.config.onIterationError` key). Containment already worked — the failure was caught, the loop went on and the run completed — but nothing said what it had contained: a sweep that lost two rows out of five reported `status=completed selected=5 acted=9 skipped=0` and was indistinguishable from one that lost none. The failure was in the step log and in `nodes[].failures`; no run-level number carried it, the failing step named no row, and `$error` bound no row identity. + + Four changes populate the contract `@objectstack/spec` already declares: + + - **`FlowRunSummary.failed`** — `summarizeRun` now folds `failed = Σ nodes[].failures` over the per-node array it publishes, so the run-level count can never disagree with the breakdown it summarizes. It counts every node execution that failed, contained or fatal; on a run that completed, all of them were contained. + - **`failed=N` on the run summary line** — `formatRunSummaryLine` prints the token whenever the count is present, `failed=0` included. That is the opposite of the `unmeasured` rule beside it and deliberate: `unmeasured` qualifies `acted`, while `failed` answers a question a completed run's line otherwise cannot be asked at all. Read `failed=0` precisely: **no node execution of this run failed**. It is the node fold and only that, so a `subflow` child's own contained failures stay on the child's summary rather than rolling up the way `acted` does — see #15617, where the declaration's two paragraphs are being reconciled. + - **Iteration through `try_catch`** — a step that ran in a `try` or `catch` region inside a loop body now carries the enclosing loop's `iteration`, with `regionKind` still `try` / `catch`. The step says which region ran it *and* which row it ran for. `parallel` branch tagging is unchanged. + - **`$error` binds the row** — the value bound to `errorVariable` (default `$error`) is the declared `TryCatchErrorValue`: `nodeId` and `message` as before, plus `iteration` and the loop's current `item` when the failure happened inside a loop body. A `subflow` / `map` child run has its own variable scope and therefore binds neither, so a parent's row identity never leaks into a child's `$error`. + + **`failed` absent means "not tracked", never `0`.** Runs recorded before this change keep it absent — no migration and no default, the same convention `unmeasured` carries. Defaulting it to zero would tell an operator "nothing failed" about a run nobody measured. Absent, the summary line prints no `failed=` token at all; present-and-zero prints `failed=0`. The count rides in the persisted `summary_json`, including on a summary compacted past the size cap, where the per-node `failures` it folds are exactly what gets dropped. +- 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + + A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. + + - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. + - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. + + **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. + + An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. +- b31ebfe: A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. + + An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated. + + Two independent halves: + + - **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`). + - **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing. + + **Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. + + **Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `Id`, or when its value equals what the bag carries under `recordId`, `Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too. + + ⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause. + + ⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change. +- 5964124: feat(automation): a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937) + + The services half of the #13937 shape-4 ruling (maintainer 2026-09-01): + `resumeInternal`'s consumption order is kept — the suspension is consumed + before downstream nodes run, which is what buys exactly-once across a crash — + and the state that order leaves behind when a downstream node throws now + carries the platform-level name #14384 put on the contract. + + `AutomationEngine.resume()` (and every engine continuation that reaches the + same catch arm) returns `{ success: false, status: 'stranded', … }` where it + returned no `status` at all. Stamped on that one exit only: the pause a + durable decision was waiting on is gone, the run is recorded `failed`, and it + can be re-armed only by the explicit operator verb + `restoreConsumedSuspension` (#13909 slice 2, already published) — never by + `resume` (which answers `RUN_NOT_FOUND`) and never automatically. Distinct + from `'failed'` on purpose: that one says the run ran and was rejected; this + one says a recorded continuation stopped mid-flight and an operator has + something to repair. The result's verdict and the restore verb are held to + agree by test: a stranded result is exactly a restorable run. + + Not changed: the run's RECORDED status (the run log, `getRun`, `listRuns`, the + durable `sys_automation_run` history row) stays `failed` — that vocabulary is + `ExecutionStatus` in `@objectstack/spec`, which the ruling did not widen; the + durable discriminator for the condition remains the snapshot the terminal row + carries. No resume semantics move for any pausing node type; shapes 2 and 3 + of the decision stay excluded. + + Also in this change, under the same ruling's exactly-once guarantee, two + repairs to how `restoreConsumedSuspension` finds a stranded run's snapshot: + + - The durable run-history row of a stranded run now records the PAUSE node in + `node_id`. It recorded the node that threw — the run's last step — and the + object store read that column back as the snapshot's node, so a restore + from the row (after a restart, or on another replica) re-armed the run at + the failed node and the next resume skipped it while reporting the run + completed. The throwing node stays in the row's step log and `error`. + Visible on the Runs surface: `sys_automation_run`'s row title and highlight + set are built from `node_id` (`titleFormat '{flow_name} · {node_id}'`), so a + stranded run's row now names the PAUSED node — the one an operator can + re-arm — where it named the node that threw; ordinary completed / failed + rows are unchanged. The `node_id` and `variables_json` field descriptions + carry this carve-out, the way `node_type`'s already did. + - The verb reads the durable row and its own per-process journal as two + witnesses of one strand instead of trusting either alone. The hot copy is + preferred when both describe the same pause (it is the verbatim object the + failure was journalled from). A row that carries no snapshot is read as + "the run moved on" only when this process's own history write landed — + the replica that stranded a run used to keep a hot copy that could re-arm + the run after another replica had restored, resumed and finished it, and + the next resume re-ran every node after the pause. A snapshot the object + store could not persist (over its 256 KiB row budget) is now recorded in + the row as dropped, with the pause it belonged to, so the replica holding + the hot copy still restores and any other replica is refused with a reason + that names the budget and the remedy. + + In-memory and store-less deployments observe no behaviour difference. On the + object store, same-replica restores re-arm the pause node on every path, and + restores from the row alone do too; restores across replicas of a run that + finished elsewhere are refused. +- 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + + `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. + + - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. + + **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. + + **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. + + An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. + +### Patch Changes + +- a775510: A run whose nodes all succeeded is no longer reported `stranded`, journalled for repair, or re-armed because its terminal run-history write threw — which made the "repair" re-run every node after the pause. + + `AutomationEngine.resumeInternal` called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. The arm journalled a repair snapshot, stamped `status: 'stranded'`, and answered `success: false`; `restoreConsumedSuspension` then correctly honoured that snapshot and put the pause back, so the next resume drove the downstream nodes a **second** time. The defence that normally prevents a re-armed run from becoming double-runnable reads the durable terminal row first, and on this path the durable terminal row is precisely what failed to land — so it could not fire. + + Two statements inside `recordLog` reach that `catch`, and both are host-supplied surfaces rather than in-repo ones. `store.recordTerminal` escapes when it throws **synchronously**: the `void write.catch(...)` beneath the call only ever sees a returned promise's rejection, and a store returning a non-thenable makes `write.catch` itself a synchronous `TypeError`. Both stores shipped in this package are `async` methods and so cannot reach it, but `SuspendedRunStore` is an exported, optional-method interface a host may implement. The second statement is the run-summary line `logger.info(...)`, which is on by default (`runSummaryLog: 'info'`) and calls a host-injected `Logger`, so it needs no store at all. + + - **The completion-path history write is now guarded at its own site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. `resume` answers the truthful `success: true`, no snapshot is journalled, `restoreConsumedSuspension` refuses with `RUN_COMPLETED`, and the downstream node runs exactly once. + - **The lost history row is still reported**, at `error`, naming in its first line that the run completed, that its terminal row never landed and nothing retries it, that the run must not be repaired or re-run, and that the driver's own failure is in the record's structured slot. + - **`restoreConsumedSuspension` is unchanged.** It judged correctly on the evidence it was handed; the evidence was what was wrong, and a completed run now journals none. A genuine node failure still journals, still reports `stranded`, and is still repairable. +- 60c0f61: A suspended run whose durable save fails is now kept resumable in this process even when a concurrent read landed mid-park — and the error record that reports the failure says how to check that. + + `AutomationEngine.persistSuspendedRun` writes its map entry BEFORE it awaits `store.save()`, and marks the run cache-only only once that save settles. For the whole of that await the entry is live but unqualified, so a concurrent per-id `hasSuspendedRun` / `resume` reads a store that truthfully has no row yet, finds no qualifier, and evicts a run that is being parked right now. That window is bounded and stays as it was: the strict load is store-first, so once the save lands the run is resumable from the store, and only the cache-only listing under-reports. + + Compounded with the save then **failing**, it was not bounded. The catch marked the run cache-only, but the map entry that marking qualifies had already been evicted, so the run had neither a durable row nor an in-memory copy: `hasSuspendedRun` answered `false` and `resume` answered `RUN_NOT_FOUND`. The run was lost **in this process**, not merely un-durable — for example a paused approval that no decision can ever advance. Reaching it needs a store that rejects the write while still answering reads with "no row" rather than throwing: a healthy read replica behind a broken write path, a missing `INSERT` grant, a full disk. + + - **The failure path now re-seats the map entry** alongside the cache-only marking, so the marking qualifies something again and the documented degradation — a failed save costs cross-restart durability, not in-process resumability — holds in this interleaving too. The cache-only marking is not widened, no lock is added, and the save is not reordered, so a run is still never readable out of the map while the store is authoritative for it. + - **The error record for a failed save is corrected.** It kept telling the operator the run was "kept in memory only" and that they had until the next restart to act, which in this interleaving pointed away from the loss: the run was already gone, and the restart would take the blame. It now names the two reads that must still answer for the run (`hasSuspendedRun()` and `listSuspendedRuns()`), so the promise can be checked rather than trusted. It still reports the same cause in the same structured slot, at the same `error` level. +- 4e090ec: `ObjectStoreSuspendedRunStore` no longer announces "no cross-replica advance guarantee is offered" *after* it has issued the guarded delete. + + `claimSuspension` is the cross-replica half of the resume idempotency guard: it removes the `sys_automation_run` row only if the run is still parked where this replica read it, and the affected-row count names the winner. The refusal for an engine that does not resolve such a count was decided on the SHAPE of the return value — one line after the compare-and-set had already gone out. On such an engine that made the refusal a statement about a write that had already landed: the conditional delete was performed against the shared row and its verdict discarded, `AutomationEngine.claimAdvance` read `'unsupported'` as `unguarded`, and a replica that **actually lost** the claim (0 rows affected) resumed anyway — running every downstream side effect a second time, on the one composition that declares itself unable to prevent that. + + The capability question is now settled before anything is claimed, and `'unsupported'` is retired as an answer once the row has been touched: + + - **A one-time capability probe, before the compare-and-set.** Once per store instance, `claimSuspension` issues one delete down the very route the claim takes (`multi: true` with a `where` carrying keys besides `id`, which is what dispatches to `driver.deleteMany`) against a sentinel predicate that matches no row — the same value in `id`, `node_id` and `correlation` at once. An engine that resolves something other than a count is refused with **nothing consumed**, so `claimAdvance`'s `unguarded` reading is true when it is taken. Concurrent first claims share one probe, and a probe that *throws* is deliberately not memoized: a store that was unreachable for one second must not answer for the life of the process. + - **After the write, an unreadable verdict is `STORE_UNAVAILABLE`, not `unguarded`.** If a probed-counting engine still resolves a non-count for a real claim, the compare-and-set is committed and its verdict is unrecoverable — a winner and a loser both find the row gone, so no follow-up read can tell them apart. The store throws instead of answering `'unsupported'`; `claimAdvance` already maps that to `STORE_UNAVAILABLE`, whose text is written for exactly this fact ("a failure can arrive after a committed delete"), and the resume is **refused** rather than continued. A claim that in fact won is then stranded until an operator retries — the deliberate direction, since a doubled side effect is the worse outcome. + + **What this does not do, stated so it is not read into it.** It does not give an uncounted engine the guarantee. `ObjectQL.delete` declares `Promise`, so "does a multi-delete return a count" has no contractual answer to look up and no read-only instrument to measure — a probe can observe the route once, never promise what the next call resolves to. Closing that gap belongs to the engine boundary, where the count is contracted one layer down (`IDataDriver.deleteMany`, `Promise`) and erased to `any` on the way up. On such a composition the store still degrades to an unguarded resume; what changed is that it says so before consuming anything, and the run's durable row is still removed by the consumption choke point exactly as before. + + Every measured shipped composition already resolves a count (memory, sql/better-sqlite3, sqlite-wasm, turso local and remote transport, sql with the security plugin composed), so the observable cost there is one extra `DELETE … WHERE` that matches nothing, once per process. It emits no hook dispatch, no realtime event and no row change: the per-row before phase is "zero matched rows is zero dispatches", the after phase iterates the same empty set, and `publishBulkDataEvent` returns at `matched === 0` by design. +- 7bf96cf: A `map` node inside a `loop` body now runs its collection on every iteration, not just the first. + + `map` tracks its progress through the collection in the flow variable `.$mapState`, and wrote it into the flow's **shared** variable scope without ever removing it. A `loop` body region runs in that same scope by construction — that is what makes the iterator variable and the body's mutations visible to the rest of the flow — so the state written by iteration 1 was still there when iteration 2 entered the map. It read back `started === collection.length`, correctly concluded there was nothing left to start, and returned. + + The result was silent partial work reported as success: measured on the engine, **5 iterations x 2 items produced 2 child runs instead of 10**, the map step reported `success` on all five iterations, and the run finished `completed`. Nothing threw and nothing was caught, so `FlowRunSummary.failed` — the run-level counter that exists to expose contained failures — reported `failed = 0` over it. An operator reading that counter was told the run was clean while it had done a fifth of its work. + + The fix is a lifetime correction, not a new key: `$mapState` is now removed once the collection is exhausted, so its lifetime is one execution of the collection rather than the enclosing scope's. + + **The durable-pause path is deliberately unchanged.** A `map` whose per-item subflow pauses still writes its progress before suspending, and still reads it back when the engine re-enters the node — that write is the mechanism resume depends on, because a resume rebuilds the variable scope from the snapshot taken at the suspend and so can never see any later write. Only the node's terminal path clears the key. A `map` resumed mid-collection continues where it left off, exactly as before, and no item is re-run. +- 0cf0867: Keep a resume's `status: 'stranded'` verdict when the bookkeeping after the repair journal throws. + + `resumeInternal`'s catch arm journals the consumed suspension — the snapshot `restoreConsumedSuspension` puts back — and only then stamps `status: 'stranded'`. Two statements sat between them and could throw out of the whole arm: `recordLog`'s terminal run-summary line, and a store whose `recordTerminal` throws synchronously (the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection). `failAncestors` follows them. + + A throw in that window left the run genuinely repairable while the verdict never shipped, and every consumer derives repairability from the verdict — `plugin-approvals` computes its operator-facing `repairable` as `status === 'stranded'` — so the approvals decision door reported `repairable: false` about a run that `restoreConsumedSuspension` answers `restored: true` for. That is a false negative on a repair instruction: it tells an operator not to attempt a repair that works. + + The window is now guarded. The bookkeeping may still fail — and says so loudly, at `error`, naming the run, what did not land, and the verb that repairs the strand — while the verdict still ships. Measured: with a store whose terminal write throws, `resume` now returns `{ success: false, status: 'stranded' }` instead of throwing, the door reports `repairable: true`, and the repair verb succeeds on that same run. + + The guard opens **after** the journal, so only a run that demonstrably has a snapshot can reach the stamp: a throw from the journal itself still propagates, every exit above the consumption point still carries no status at all, and cascade-failed ancestors — which journal nothing — are untouched and still correctly non-repairable. + + ⚠️ This change also makes a pre-existing fault **visible** rather than creating it. The completion path's history write sits inside the same `try` as the node-failure arm, so a run that **completed** — every node succeeded — is journalled and reported `stranded` when its `completed` history row throws, and repairing such a run **re-runs the flow**. That phantom, its repair snapshot and the double run were all measurable before this change; what changes here is only that more store failures now report the verdict instead of throwing over it, so an operator can now be told to repair a completed run. Filed as #15944, with the measurement on both trees. + + ⚠️ `repairable` remains a point-in-time fact, and this change does not make it durable: the run in the case above has no terminal history row (that write is what failed), so the repair rides on the in-memory journal and a restart loses it. The verdict reports what an operator can do now, which is exactly what was being denied. +- 1375344: automation: a subflow parent left STRANDED by a failed up-bubble is reported at `error`, not `warn` + + When an approval (or any pause) sits inside a subflow child, resuming the child + bubbles up to the parent. If the parent's own continuation then fails on the + engine's stranded exit — its suspension consumed, a repair snapshot journalled, + the run recorded `failed` — nothing but a `warn` said so, while the child's + resumer (an approvals decision door, a wait timer) was told the resume + succeeded. Persisted state and runtime state disagree and nothing looks broken + from the outside, which is the durability class. + + `bubbleToParent` now grades that record by the engine's own + `AutomationResult.status` discriminator: `'stranded'` is reported at `error`, + naming the parent run and the `restoreConsumedSuspension` verb that repairs it. + Every other parent-resume failure — a concurrent resume, an unreachable store, + a thrown resume — stays at `warn` unchanged, on a narrower ground: those exits + carry no `'stranded'` discriminator. `'stranded'` is the one exit that journals + a repair snapshot, so it is the one an operator can act on, and grading by the + engine's own verdict is what keeps `error` readable. + + ⚠️ That is a statement about what this seam can KNOW, not a guarantee that + every other exit left the parent healthy. Two exits are known not to be: + + - a **thrown** parent resume carries no discriminator at all, and #15555 + documents a window in which a throw between the journal and the stamp hides a + parent that IS stranded. Left at `warn` deliberately, for that card; + - the **claim-path** store failure reports, in its own envelope text, that + whether the suspension was consumed is UNKNOWN — it relies on a retry to + settle it, and an up-bubble has no retrier. ("Not consumed" is the guarantee + of the strict-load store failure only, not of every store failure.) + + ⚠️ This is the log half only. What the child's resumer is told is unchanged. +- 1157e7b: fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms (#15832) + + `AutomationEngine` had exactly one eviction site for its `suspendedRuns` + map, inside `forgetSuspendedRun` — and that runs in whichever process + **consumes** the suspension. In a multi-replica deployment that is routinely + not the process that parked it: replica A parks a run, replica B resumes it, + and nothing ever removes A's entry. There is no invalidation channel from B + to A. + + The card that found this located the leak on `resumeInternal`'s + `claim.kind === 'lost'` branch, which returns before that choke point. That + branch does leak, but it is not the common shape: the **no-race** variant + leaks identically — A parks, only B ever resumes, A never attempts a claim + and there is no `'lost'` anywhere in the sequence — so an eviction hung on + `'lost'` alone would have left the ordinary deployment untouched. + + The retained snapshot was **not only memory**. Two readers handed it back: + `listSuspendedRuns()` (synchronous, cache-only, and the one listing on the + `AutomationService` spec contract) and `listSuspendedRunsDurable()` (which + deliberately appends map entries the durable list lacks). Once the other + replica **completed** the run, both reported a phantom — a finished run + listed as suspended, whose `getSuspendedScreen()` answers `null`, so a + consumer that listed and then opened got an entry it could not act on. + + An entry is now dropped whenever this process holds a store-authoritative, + per-id "no row" answer for it: the strict loader's store miss (which reaches + `resume`, `hasSuspendedRun`, `cancelRun` and `getSuspendedScreen`), a lost + advance claim, and a bounded per-id reconcile for the map-only entries of + `listSuspendedRunsDurable()`. + + **Nothing here moves the cache-only listing's contract.** The fix only ever + *removes* entries. The spec says `listSuspendedRuns()` lists "the currently + suspended (paused) runs awaiting a resume"; the engine's own docblock adds + only that it may OMIT runs (those parked in a previous process lifetime), + because it reads the cache alone. Under-reporting is therefore already + inside the declared latitude, and over-reporting was never inside the + promise. Neither listing becomes store-backed, and `listSuspendedRuns()` + stays synchronous. + + Three shapes are deliberately **never** evicted, each pinned by a control: + no store attached (the map IS the authority); a run whose durable save + failed (`cacheOnlySuspensions` — the store was never handed the row, so its + silence says nothing about it); and a store read that THROWS (an outage + means the run's existence is unknown, not gone). A failed `list()` + enumeration likewise triggers no per-id reconcile — during an outage that + would ask about every live run in the process. + + **Residual, stated rather than implied.** Eviction is demand-driven: a + phantom is cleared when this process next obtains the per-id answer for that + run — any `resume` / `hasSuspendedRun` / `getSuspendedScreen`, or a + `listSuspendedRunsDurable()` reconcile. A process that never looks at the + run again keeps the entry until it does. With no invalidation channel + between replicas, closing that last gap needs either a background sweep or a + store-backed listing, and both are decisions above this change; the boundary + is pinned by a `RESIDUAL` test rather than left to be discovered. + + Note 2 of the same card — the `'unsupported'` branch deciding on the shape of + a value the conditional delete has **already** been issued to obtain — is + **not** addressed here: its honest fix is a declared return contract for the + engine's multi-row delete, which lands in another package. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- 6c439f2: Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition. + + The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back). + + The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none. + + The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition. + + Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [098cbb7] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index dbca46d152..6800e28dba 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine", "type": "module", diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md index a296879af8..2c0e818284 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/service-cache +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 24975c034d..f92c47d252 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters", "type": "module", diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md index 11f8eb3ae6..ccbbf9017d 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/service-cluster-redis +## 17.4.0 + +### Patch Changes + +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + - @objectstack/service-cluster@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index e906029708..eb03afe5bc 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.", "type": "module", diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md index 15e0461343..91c4ff534c 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/service-cluster +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index b9494cc0a4..befd8fb6df 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.", "type": "module", diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md index 7332dd4768..3346b6d49b 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,163 @@ # @objectstack/service-external-datasource +## 17.4.0 + +### Patch Changes + +- fb447b4: The datasource admin routes derive the tenancy posture before resolving the caller + + `requireDatasourceAdmin` resolved the request with `resolveAuthzContext({ ql, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran on this family, and an API key stamped with an organization its owner had left was admitted; the routes then gated it on `authz.systemPermissions` alone. Because this family gates on system capabilities rather than on organization-scoped rows, the consequence was an admitted principal rather than a cross-organization row read. + + The posture is now read off the kernel's `tenancy` service and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Patch rather than minor: no accept set widens, and a declared guard returns to enforced. +- e9fcd6b: fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) + + `buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — + still consulted `config.timeout` after #15680 renamed that authored key to + `timeoutMs` and tombstoned the old spelling. A turso datasource authored the + canonical way therefore reached the seam, matched nothing, and had its timeout + **silently dropped**: no diagnostic in any channel. + + The reader now consults `config.timeoutMs`. The DRIVER key it lands on is + unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is + published-but-inert (#16024), and renaming an inert key would ratify it as real, + which is what ADR-0049 exists to prevent. So this seam is the one place the + authored and driver spellings differ, and it now says so. + + ## No fallback arm for the retired spelling — the seam's own precedent + + Both sibling arms in `default-datasource-driver-factory.ts` already answer this + in the same words: sqlite's "`filename` is the whole contract … so no `??` + tolerance survives here", mongo's "`url` is the one spelling". A renamed + datasource config key reaches a reader already canonical from two directions — + authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` + `never` plus a parse-time prescription), and a stored `sys_metadata` row replays + the full ADR-0087 chain including `retiredFromLoadPath` entries at + `loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion + `turso-config-timeout-to-timeout-ms` has rewritten the key before this table + sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a + spelling both doors have closed. + + `authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route + (host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs + itself, which never meets the authoring schema), not for a retired spelling. + + ## Why the covering test did not catch it, and what replaces it + + `TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a + rename through it — the tombstone's type channel, which caught the alias tables + elsewhere in this stack, does not reach here. And the covering test authored the + **retired** spelling at all three of its turso `config` sites, so it was green + for exactly the behaviour that had become wrong. A test that pins the retired + spelling cannot notice this class of bug. + + The three sites now author the canonical spelling, and the file gains cases + DERIVED from the authoring contract rather than written against today's key + list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert + that (a) every canonical replacement is consulted by some reader, and (b) no + retired spelling is — probed at every JS type a reader could type-test, with a + vacuity guard so a mis-derived empty list fails instead of passing. They hold + for the next rename without being edited. + + The two sibling pins that author the same spec — `packages/cli`'s driver + correspondence check and `packages/runtime`'s cross-loader convergence check — + move to the canonical spelling with it; their assertions read driver keys and + are unchanged. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [a06faeb] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [33e939f] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/driver-turso@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/driver-mongodb@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 9ca907fce8..4194f355e4 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.", "type": "module", diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md index 76f67d400f..ede4a7fbe3 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,96 @@ # @objectstack/service-i18n +## 17.4.0 + +### Minor Changes + +- a84e1ce: feat(service-i18n): `FileI18nAdapter.getFallbackLocale()` reports the `fallbackLocale` the adapter was constructed with (#14882) + + Implements the new optional `II18nService.getFallbackLocale()`. `I18nServicePlugin` + already receives `fallbackLocale || defaultLocale || 'en'` from the stack's `i18n` + config on both boot paths (`os serve`, the dev plugin); this makes that declaration + readable, so the REST metadata reads pass the document translators the same fallback + locale `t()` itself consults. Returns `undefined` when no `fallbackLocale` was given. + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 870371fc27..c118fb2f22 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", "type": "module", diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md index 857d07754e..4c6462dab7 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,100 @@ # @objectstack/service-job +## 17.4.0 + +### Patch Changes + +- e9fcd6b: fix(service-job): `runWithPolicy` and the DB job adapter read `JobScheduleOptions.timeoutMs` (#14478) + + The per-attempt time limit is read from `options.timeoutMs`, following the + `@objectstack/spec` rename of both the authored `job.timeoutMs` and the + `JobScheduleOptions` contract key that carries it. Same value, same per-attempt + race, same `JobTimeoutError`; `withoutPolicy` strips the renamed key so the + timer adapter downstream never runs a second budget. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 5aa0972418..440dc2ab4e 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling", "type": "module", diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md index 0356f7dbd9..a3c53ade69 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/service-knowledge +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 2c946d4056..0d00598724 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 0836d1f2d7..be5751b5c5 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,96 @@ # @objectstack/service-messaging +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 6cb7ae7101..928d1655d0 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index 356f030132..d6b3344762 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/service-package +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 62734b900e..b64894c8da 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index 4eb39e12fe..ea4d5a6e1c 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,93 @@ # @objectstack/service-queue +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 7915c9cca5..56061c80e0 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters", "type": "module", diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md index 225ff38c88..b8321abc4d 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,93 @@ # @objectstack/service-realtime +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index d1f8de464e..c9af30dc07 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", "type": "module", diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md index df51666809..b938f62443 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/service-settings +## 17.4.0 + +### Minor Changes + +- 6b8c677: fix(service-settings): the settings door answers from the ONE shared value-domain predicate, and refuses a non-member with `value_domain` (#15162) + + + + **BREAKING** for a client that branches on the refusal code. Landing inside + the launch window, so it ships as `minor` (the lockstep convention forbids + `major`); the banner is the carrier, not the bump. + + The services half of the maintainer's ruling of 2026-09-02: **one closed + vocabulary and one membership predicate shared by settings specifiers and + object fields**. The spec half declared them in `@objectstack/spec/shared`; + this package had been carrying a second copy of all three definitions since + `Specifier.valueDomain` shipped. The copies are deleted and the door now asks + `isValueDomainMember` — the call the record write path will make when the + engine half of the same ruling lands (PR #15316, still open). + + **The wire change**, measured on `PUT /api/settings/localization` with + `{"timezone": "Mars/Olympus"}`, base `a56baa2bd` vs this branch: + + | | before | after | + |:--|:--|:--| + | `fields[0].code` | `invalid_value` | `value_domain` | + | `fields[0].message` | `Default timezone must be a valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received 'Mars/Olympus'.` | `Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")` | + + Everything else is byte-identical: HTTP 400, the envelope code + `SETTINGS_VALIDATION`, `field`, `label`, `constraint: { valueDomain: … }` and + the echoed `value`. A client that reads `constraint.valueDomain` — the + machine-readable half ADR-0114 asks it to read — is unaffected. A client that + branches on `code === 'invalid_value'` for a domain breach must move to + `value_domain`. + + Why the code moved: ADR-0114's rule is that the code is the **constraint's own + name**, the way `max_length` names the bound it breached. This branch took + `invalid_value` — the catalog's slot for "rejected for a reason no other + member names" — only while no member named a standard-domain breach. The + field-level card's spec half added one, so the slot no longer applies. The + message now renders the published catalog template + `value_domain_` in `en` — the catalog the record write path will render + from once PR #15316 lands, so the two doors under one ruling will describe one + domain in one set of words instead of each composing its own sentence. For an `encrypted` specifier the offending value is still never + echoed: the template's value placeholder takes the same mask the REST boundary + uses (`fields[0].value` stays absent, as before). + + **No value changes verdict.** The accept sets were measured, not assumed, on + the repo's Node 22 baseline (v22.22.2): + + - `iso_3166_alpha2` — the two 249-code lists diffed mechanically before either + was deleted: identical, including order; symmetric difference 0. + - `iso_4217_currency` — this one changes DEFINITION: a run-time + `Intl.supportedValuesOf('currency')` probe becomes the key set of the + checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`. 162 codes vs 162, + symmetric difference 0 in both directions (`CHF` in both, `XYZ` in neither). + The behaviour that changes is that the verdict no longer varies with the + host's ICU build — the direction the shared module argues for. A door-level + test now re-measures it: every code the run-time probe admits must still be + admitted. + - `iana_time_zone` — the identical `Intl.DateTimeFormat` probe on both sides, + unmoved. + + A ratchet pin (`value-domains.shared-predicate.pin.test.ts`) reddens if any + non-test source in this package re-acquires a membership table, an `Intl` + enumeration probe, or a second caller of the predicate. + +### Patch Changes + +- 2024eca: Fix: the settings REST doors now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply here — and the tenant this seam hands onward is a vetted one. + + Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran at this door, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. This gate does not merely admit the principal: it returns that tenant onward as the resolved settings tenant, so an unvetted claim became the verdict the read/write path acted on. A browser session whose stored active organization is no longer backed by a membership now has that claim dropped here too, rather than passed through. + + The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage rather than a quiet admission. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index 072020d7d3..e0b0225e62 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.", "type": "module", diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md index 84e7dd7d4e..295b6bdfa3 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,90 @@ # @objectstack/service-sms +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [d4c2cb1] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [9f39897] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/plugin-auth@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index afaea4ba6d..b432166a24 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", "main": "dist/index.js", diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md index c0b48485bb..33fbc6abf2 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,148 @@ # @objectstack/service-storage +## 17.4.0 + +### Patch Changes + +- ebb5550: fix(service-storage): put the test layer in front of tsc, and repair what it was hiding (#15050) + + `packages/services/service-storage` had **no `typecheck` script at all** — its + scripts were `build` and `test` — so no tsc program anywhere read this + package's test layer, and its errors were carried instead as a 51-error DEBT + entry in `scripts/check-type-check-coverage.mjs`. Gives it the #14062 / + #14181 "checked test zone" shape: a sibling `tsconfig.test.json` (module + semantics only — `esnext` / `bundler` / `lib: ES2022` — matching how vitest + actually executes these files; strictness inherited and untouched) plus a + `tsconfig.scripts.json` for `scripts/i18n-extract.config.ts` (the ninth + instance of #11351, previously excluded from that ledger only because this + package had no `typecheck` script to hang it on), both named by a new + `typecheck` script. + + Measured before repair: 51 errors under BUILD semantics (`tsc --noEmit -p + tsconfig.json`, which already includes the tests — matching the DEBT entry's + recorded number exactly), 10 under the split. Unlike `service-cluster` + (#14181), this package's BUILD reading was *not* already clean, so both + programs needed genuine repair, not just the test-only split: 23 `TS2835` + (relative imports missing their `.js` extension, required under BUILD's + NodeNext resolution) were fixed by *adding* the extension — which resolves + correctly under both NodeNext and the split's bundler mode — and clearing + that also cleared all 15 `TS7006` "implicitly any" as a downstream cascade + from the same unresolved imports (the shape `@objectstack/core` reported at + 98 → 4). The remaining 3 `TS2550` (`Array.prototype.at` needing `lib` + es2022) are rewritten to indexed access rather than widening the shared + BUILD `tsconfig.json`. The 8 code-tier errors (`TS2339` × 4 — a test + helper's object-spread dropped its `Record` index + signature, fixed with an explicit return-shape annotation; `TS2347` × 4 — a + fake `ctx: any`'s `getService(...)` calls converted to `getService(...) + as T`, the pattern one call site in the same file had already adopted for + exactly this reason) are genuine test-file fixes. Both readings now agree at + 0/0 — the same result `service-cluster` reported, reached by a longer road. + + The package's DEBT entry (51 errors) is **deleted**, not lowered — the + graduation this ratchet's invariant requires. No `test-typecheck-debt.json` + is added: residue is 0, so none is owed (#5286, maintainer-only to open). + `check:type-source-resolution` went red from onboarding the two new + programs (the documented onboarding-limb case): a registry entry is added + rather than `paths`, measured both ways — `paths` takes this package's test + layer from 0 errors to 306, all in other packages' source. + + No runtime code changes: `src/**` excluding tests is byte-identical, so no + shipped behaviour moves. The `patch` level reflects the published + `package.json` gaining `typecheck` / `check:test-typecheck` scripts and a + `tsx` devDependency. +- b8c82de: The storage download door derives the tenancy posture before resolving the caller + + `buildFileReadAuthorizer` resolved every gated download with `resolveAuthzContext({ ql: engine, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran at this door. Its headers come from the real request, so `x-api-key` is accepted, and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture a key stamped with an organization its owner had left therefore authenticated for downloads and was judged by the ownership and record-reachability checks — checks evaluated for a principal the wall should have refused at the door. + + The posture is now read off the kernel's `tenancy` service, per download, and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Under `isolated` and `group` an ex-member's stamped key is now refused and no download capability is minted; an organization-less key is refused under `isolated` and stays admitted under `group`, whose union scope makes it legitimate. Under `single` nothing changes. Patch rather than minor: no accept set widens, and a declared guard returns to enforced. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [6acb37e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index a72054e07c..3020f00a8e 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 25e75bfc4b..0229de7d22 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,2447 @@ # @objectstack/spec +## 17.4.0 + +### Minor Changes + +- e9fcd6b: feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) + + + + **BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescriptions are registered under protocol major + 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions; this card clears the `api/` directory against it. Measured with the + gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree + count falls **48 → 36**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | + | `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | + | `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | + | `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | + | `RouteDefinition.timeout` | `timeoutMs` | milliseconds | + | `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | + | `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | + | `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | + | `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | + + **Every value is unchanged** — only key names move. Every old spelling is a + `retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type + `never`) and fails the parse with the rename prescription rather than a bare + unrecognized-key error. + + ## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch + + Ruling B put this key explicitly in scope with its own BREAKING note: the + runtime-emitted measurements are read by humans and agents even though nobody + authors them. A consumer meets two retry-after values on one 429 — this + ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` + header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. + Spelled identically they read as one value in two places. + + **The HTTP `Retry-After` response header is a separate, unchanged surface.** Its + name is fixed outside this repo and nothing here touches it. Do not "fix" the + header to match the envelope, and do not read a surviving `retry-after` in + transport code as leftover work. + + ## Dispositions — one D2 conversion, five semantic entries + + Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only + one of the twelve that gets an ADR-0087 D2 conversion** + (`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack + collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered + metadata kind stored as a row, so the conversion chain has a seam that sees it. + `os migrate meta --from 17` lists the mechanical edits. + + The other eleven are wire payloads and construction arguments — a device-flow + response body, an error envelope, REST-plugin route registration, a batch-loader + config, a router registration, WebSocket client/server configuration. None is + ever a stack collection member or a `sys_metadata` row, so no conversion seam + runs on them and each carries a **semantic** entry instead: this is the + disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these + very shapes, and what ruling B prescribes for a runtime-emitted key. + + ## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror + + Attributed to RFC 8628 by the campaign card; the attribution fails against the + schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as + a set — `code` is not `device_code`, `verificationUrl` is not + `verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a + different type, an ISO-8601 instant where the RFC carries a relative lifetime). + A schema that already renames every RFC field it carries into house style cannot + claim the standard fixes the one name it left bare. Renamed rather than marked + deliberately: a wrongly marked key is exempted permanently and silently, while a + wrongly renamed one is visible. + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/runtime`'s policy chain (`computeCacheControl` now reads + `endpoint.cacheTtlSeconds`), the publish gate's issue path + (`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase + example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` + tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is + retargeted onto the live key — an alias must point at a key the schema really + accepts, and `cacheTtl` now accepts nothing. +- 8f404a5: feat(spec)!: `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219) + + + + **BREAKING** accept-set narrowing on `AssembledPackageBodySchema` — the body + under `packages[i].manifest` of a release artifact (ADR-0130 D4): a body that + carries `plugins` or `devPlugins` is now **refused** at the manifest's strict + close (`unrecognized_keys`, naming the key), where it used to parse. Shipped as + `minor` under the repo's launch-window convention for breaking changes; the + hand-migration prescription is registered under protocol major 18. Maintainer + ruling 2026-09-04 on #15219 (director decision batch #32, verbatim 「同意」): + option A for both keys. + + `plugins` and `devPlugins` were members of the assembled-body key set by the + same derivation every other collection uses (`COMPOSE_KEY_DISPOSITIONS` gives + both `concat`). They are the only members whose values are **runtime assembly + instructions** rather than serialisable metadata: `plugins` holds what a host + hands to `kernel.use()` — live plugin instances, manifests or package names — + and `devPlugins` is the `os dev` load list. Inside an artifact a package body + is inert JSON, so a plugin under `packages[i].manifest` could never be + constructed by a loader; every reader reads the top level. The classification + is corrected rather than special-cased: an artifact carries metadata, a host + assembles plugins. + + **What changes** (`packages/spec/src/stack.zod.ts`): + + - `plugins` / `devPlugins` are **envelope keys** — top level only, never inside + `packages[]`. `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` (`packages`, `plugins`, + `devPlugins`) is declared once and feeds both the `AssembledPackageBodyKey` + derivation and `assembledPackageBodyShape()`. + - Both keys stay `concat`: a live stack still concatenates its plugins to the + top level under `composeStacks`, and `manifest: 'preserve'` no longer folds + them into any package body. + - The two declarations on the stack schema are unchanged. + + **What does NOT change:** `os serve` / `os migrate` / `os dev` keep reading the + top level (now correct by construction); no CLI, core or runtime code moves. + + ## FROM → TO + + ```ts + // before — a package body inside an artifact could carry plugins nobody could load + { packages: [{ manifest: { id: 'com.example.crm', /* … */ plugins: [{ name: 'plugin.x' }] } }] } + + // after — plugins live on the artifact envelope only; the body above is refused: + // packages.0.manifest: unrecognized_keys ['plugins'] + { plugins: [new CrmPlugin()], packages: [{ manifest: { id: 'com.example.crm', /* … */ } }] } + ``` + + **Migration.** Declare `plugins` / `devPlugins` at the stack top level and + delete them from every `packages[i].manifest`. An existing multi-package + artifact that carries `packages[i].manifest.plugins` (if `os build` ever wrote + one — not directly measured) is refused on load after this change and must be + rebuilt from source; a hand-written `packages[]` entry drops the keys. Stacks + that only ever declared the two keys at the top level parse byte-identically. +- 3e3ecb0: The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects. + + `SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured. + + The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen. + + Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier. + + A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair. + + Refs cloud#1967. +- c463d03: feat(spec)!: retire the incident-response, training and change-management families whole — nineteen defs and every name they exported — and the `ESignatureConfig` deadline pair (#15513, #14477, ADR-0049) + + + + **BREAKING** — published exported symbols leave `@objectstack/spec/system`, and + two authorable keys leave `data/ESignatureConfig` — landing after the v17.0.0 + cut (the lockstep launch-window convention ships it as `minor`; the + registrations live under protocol major 18, where `os migrate meta` users will + look). Maintainer ruling 2026-09-05 on #15513 (decision batch #40, ruled A: + retire the three compliance-shaped families whole via `RETIRED_DEFS_BY_MAJOR`, + the `integration/ErrorMappingConfig` precedent; none of the three is + roadmapped) and, in the same stroke, the answer the 2026-09-02 ruling on #14477 + had held open (no roadmapped e-signature consumer ⇒ the pair retires with the + rest). ADR-0049 enforce-or-remove decides it — declared-but-unenforced surface + with zero measured readers comes off. + + ## What leaves the public surface — the three families, whole + + Nineteen defs (the card counted fifteen; the manifest counts nineteen — the + ruling names the families, the number is the files' reading), forty-five + exported names, roughly a hundred declared keys, and the generated reference + pages `references/system/incident-response`, `training` and + `change-management`: + + | file | defs (`json-schema.manifest/system.json` spelling) | + |:--|:--| + | `system/incident-response.zod.ts` | `system/Incident`, `system/IncidentCategory`, `system/IncidentNotificationMatrix`, `system/IncidentNotificationRule`, `system/IncidentResponsePhase`, `system/IncidentResponsePolicy`, `system/IncidentSeverity`, `system/IncidentStatus` | + | `system/training.zod.ts` | `system/TrainingCategory`, `system/TrainingCompletionStatus`, `system/TrainingCourse`, `system/TrainingPlan`, `system/TrainingRecord` | + | `system/change-management.zod.ts` | `system/ChangeImpact`, `system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`, `system/ChangeType`, `system/RollbackPlan` | + + With them: every `*Schema` const, every `z.input` alias (`Incident`, + `IncidentResponsePolicy`, `TrainingCourse`, `ChangeRequest`, …) and the six + `*Parsed` aliases (`IncidentNotificationRuleParsed`, + `IncidentNotificationMatrixParsed`, `IncidentResponsePolicyParsed`, + `TrainingCourseParsed`, `TrainingPlanParsed`, `ChangeRequestParsed`). + + **Why.** The schemas were exported from `@objectstack/spec/system`, mounted by + no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 + liveness ledgers, and **read by nothing**: the reader census over every package + outside `packages/spec` (tests and changelogs excluded), over `examples/**` and + `skills/**`, and over objectui at the pinned sha (`a472b07`) returned zero hits + for every one of the forty-five names, with a lit control on the same pattern + (`ObjectSchema` / `FieldSchema`: 336, 200 and 342 hits per leg). Several keys + were boolean capability claims of exactly the shape ADR-0049 names — + `IncidentNotificationRule.notifyRegulators`, + `IncidentResponsePolicy.requirePostIncidentReview`, `TrainingCourse.mandatory`, + `TrainingPlan.trackCompletion` / `sendReminders`, + `ChangeRequest.approval.required`, + `ChangeRequest.securityImpact.requiresSecurityApproval` — so an author writing + `notifyRegulators: true` held a compliance promise the platform never kept, + with no error and no feedback, and the reference docs advertised a compliance + subsystem that does not exist. Tagging the families + `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is + a human-only signal, and an AI generating from the schema still writes the key + and believes it. + + **What happened to the fourteen #14477 deadline-key tombstones** (PR #15514, + merged 2026-09-04): they leave with their defs' source. Their fourteen + `RETIRED_KEYS_BY_MAJOR[18]` entries and three D3 entries stay as history — gate + (b2) of `build-schemas.ts` accepts an entry naming a key the build no longer + emits, and the 17→18 upgrade guide still owes the reader those prescriptions. + `deadline-keys-retirement.test.ts`, whose every pin needed the schemas to exist, + is replaced by `compliance-families-retirement.test.ts`. + + ## What is refused — the `ESignatureConfig` pair + + Authoring `expirationDays` or `reminderDays` on an `ESignatureConfig`, with any + value, on the base schema and through `Document.eSignature`. The schema is not + `.strict()`, so each key is a `retiredKey()` tombstone rather than a bare + deletion (a deletion would have stripped it in silence): authoring it is a `tsc` + error (`never`) and a parse error carrying the prescription (`invalid_type` at + the path of the key). Both carried defaults (30 days, 7 days) that were + materialized into every parsed configuration without ever being consulted; + parsed configurations no longer carry them. `provider`, `enabled` and `signers` + stay, byte-identical. Census for the pair: zero hits for `expirationDays`, + `reminderDays`, `eSignature` and the `ESignatureConfig` names on all three legs, + control lit inside `packages/spec` (`document.zod.ts` 9, `document.test.ts` 24). + + **Unmeasured, verbatim:** `cloud` and real customer configurations are + UNMEASURED for both the families and the pair — this census covers this repo + and objectui at the pin. + + ## FROM → TO + + ```ts + // before — imported and parsed green; no engine ever read a single key + import { IncidentResponsePolicySchema, type IncidentResponsePolicy } from '@objectstack/spec/system'; + const policy: IncidentResponsePolicy = { + notificationMatrix: { rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], notifyRegulators: true }] }, + defaultResponseTeam: 'security_team', + requirePostIncidentReview: true, + }; + IncidentResponsePolicySchema.parse(policy); + + const signing: ESignatureConfig = { + provider: 'docusign', + signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], + expirationDays: 30, + reminderDays: 7, + }; + + // after — the import is TS2305 and there is no replacement to point at, because + // no incident-response, training-management or change-management engine exists. + // A compliance record the organisation keeps is ordinary object data, declared + // as an object with its own fields and enforced by the object engine; an + // approval that must actually gate something is a flow (ADR-0018) with an + // approval node. + // + // The e-signature pair: delete the keys. `ESignatureConfig` itself stays. + const signing: ESignatureConfig = { + provider: 'docusign', + signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], + }; + ``` + + One-line fix: delete the import (families) or the key (pair) wherever it is + authored. There is no `os migrate meta` edit list — none of the schemas is a + stack collection member and `document` is no metadata type, so the conversion + chain has no seam to walk (the `MetadataPluginConfig.additionalTypes` + precedent); the tombstone prescriptions, the `tsc` refusals and the protocol-18 + upgrade guide are the channels. + + The retirement kit: + + - the three schema files and their tests deleted whole; the survivor notes in + `packages/spec/src/system/index.ts` record what each module declared and why + nothing ever read it + - ADR-0087 registration: nineteen `RETIRED_DEFS_BY_MAJOR[18]` entries + (`entries/retired-defs/18.system__*.ts`) and three D3 semantic entries, one + per family; for the pair, `data/ESignatureConfig:expirationDays` and + `data/ESignatureConfig:reminderDays` in `RETIRED_KEYS_BY_MAJOR[18]` plus the + D3 entry `esignature-config-deadline-keys-retired`; the step-18 `rationale` + extended + - no liveness-ledger row: none of the families and neither `document` nor + `ESignatureConfig` is an enrolled ledger type, so there is no row to keep or + drop + - pin tests: `compliance-families-retirement.test.ts` (zero holders of the + forty-five names on every public entry via `export-origins/`, the deletion + probe, the in-package importer walk, the runtime namespace, the shards' + absence, the ADR-0087 registration, the #15514 history kept, and a + tree-scoped absence leg whose walk radius is DECLARED in + `scripts/cross-package-test-inputs.mjs` / `turbo.json` — the playbook rule + #15566 added after PR #15514); `esignature-deadline-keys-retirement.test.ts` + (refusal pins asserting issue path, code and prescription on the base schema + and through `Document.eSignature`; the tsc `never` channel; no-materialize + pins for the two former defaults; the ADR-0087 registration); the thirteen + isomorphism pins the three modules held leave `type-alias-convention.pin.test.ts` + - generated baselines and docs follow the schema: `json-schema.manifest/` + loses nineteen keys (the manifest-deletion gate adjudicates whole-def + removals against the merge base), `api-surface/`, `declaration-map/`, + `export-origins/`, `authorable-surface/` and `authorable-defaults/` lose the + families' rows, `authorable-surface/data.json` gains two `[RETIRED]` rows and + `authorable-defaults/data.json` loses two, the three system reference pages + are removed and `references/system/index.mdx`, `references/index.mdx` and + `references/data/document.mdx` regenerated, `spec-changes.json` and the + upgrade guide carry the four new registrations at the 18 cut + - hand-written docs: the `Change Management` row leaves + `getting-started/quick-reference.mdx` + - zero authored occurrences in this repo's examples, skills and hand-written + docs beyond that row, and zero hits in objectui at `a472b07`, so no sibling + change and no pin bump ride along +- 64bd6a3: feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848) + + + + **BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` + — shipped as `minor` under the repo's launch-window convention for breaking + changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38 + item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge + instead of dropping it. + + **What changed.** `'merge'` was implemented as + `{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`: + `fields` was the only key merged, and every other key the later object carried + — `actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier + package's value wholesale, with nothing at compose, build or boot saying so. + Two packages each embedding an action on one shared object composed to the + later package's array alone; the earlier package's action was gone. + + Now, when both objects declare an object-level **collection** other than + `fields` with different values, `composeStacks` throws — the refusal shape + `'error'` uses — naming the object, the colliding collection and both stacks + by manifest id: + + ``` + composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). + objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote. + Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. + ``` + + The refusal set is **derived from `ObjectSchema`'s shape** — every key whose + declared type is an array or a record (through optional/default wrappers and + into a union's members), except `fields` — not hand-listed, so a collection key + added to the object schema joins the refusal without an edit to the composer. + Today that set is `actions`, `activityMilestones`, `fieldGroups`, + `highlightFields`, `indexes`, `listViews`, `requiredPermissions`, + `searchableFields`, `validations`. + + **What did not change.** + + - `fields` keeps its documented shallow merge (later fields win, earlier + fields kept). + - **Identical** declarations on both sides pass through and are carried once + — the same reading `composeStacks` already gives identical top-level values + — so two built stacks that each bind one standalone action to the same + object (identical copies) still reach the cross-stack action-key check + (#14662) and are refused there, by name, as before. + - A scalar or fixed-shape config object the later object declares (`label`, + `sharingModel`, `enable`, `access`, …) still replaces the earlier one: the + ruling narrows collections only, and the docblock now says so. + - The default `'error'` and `'override'` are untouched, message for message. + - An explicit `undefined` on the later object is read as no declaration — it + neither counts as a differing value nor erases what the earlier stack + declared (the bare spread used to let it). + + **Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test + call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at + all — every real caller takes the default `'error'`. An external author who + opted into `'merge'` and relied on the later package's collection winning + silently now gets the refusal above; the fix is the one it names. + + The `ConflictStrategySchema` docblock for `'merge'` states the rule. +- 13c48c2: feat(spec): retire `connector.errorMapping` — eleven authorable keys nothing ever read, one of them spelled like the live `userMessage` channel (#14676, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + Triage ruling 2026-09-02 on the census card: ADR-0049 enforce-or-remove decides + it — declared-but-unenforced authorable surface with zero measured pull for a + reader comes off. + + `ConnectorSchema.errorMapping` carried `ErrorMappingConfig` (`rules`, + `defaultCategory`, `unmappedBehavior`, `logUnmapped`) and its + `ErrorMappingRule[]` (`sourceCode`, `sourceMessage`, `targetCode`, + `targetCategory`, `severity`, `retryable`, `userMessage`) — eleven keys on the + published authorable surface that **nothing read**: measured on `origin/main`, + the only reference outside the declaring file and its unit test was a + type-identity pin. No provider, dispatcher or materializer ever mapped an + external error through the rules, so `unmappedBehavior` configured nothing and + a rule's `userMessage` was never shown to anyone. That spelling is what made + this worse than ordinary dead surface: it is the name of the **live** + API-error channel (`ApiError.userMessage`, the user-facing refusal text a + thrown HTTP error declares), so an author who had read that documentation and + wrote a connector rule reasonably believed they were marking a refusal for an + end user — and the failure was silent in both directions (it validated, it + published, no message was ever shown). Removal resolves the collision by + deletion; the live channel is untouched. + + **What is refused:** authoring `errorMapping` on a connector, with any value. + `ConnectorSchema` is a non-strict `z.object`, so the key is a `retiredKey()` + tombstone rather than a bare deletion (a deletion would have stripped it in + silence): authoring it is a `tsc` error (`never`) and a parse error carrying + the prescription, on the base schema and — through + `DeclarativeConnectorEntrySchema`, which `superRefine`s the same shape — on + `stack.connectors[]` and the `PUT /api/v1/meta/connector/:name` door. + + **What leaves the public surface:** `ErrorMappingConfigSchema` / + `ErrorMappingConfig` / `ErrorMappingConfigParsed`, `ErrorMappingRuleSchema` / + `ErrorMappingRule`, and `ConnectorErrorCategorySchema` / `ConnectorErrorCategory` + (the enum's only consumers were the two removed shapes; an exported value + schema with no consumer reads as a capability). `api/ErrorCategory` — the + HTTP-response vocabulary — is unaffected. + + **What stays, byte-identical:** every other connector key (`health`, `retry`, + `webhooks`, `fieldMappings`, `syncConfig`, `actions`, `triggers`, `provider`, + `providerConfig`, `auth`, …) with its default and its readers. + + ## FROM → TO + + ```ts + // before — parsed green; nothing ever read the block, no message was ever shown + defineStack({ + connectors: [{ + name: 'payments_api', + label: 'Payments API', + type: 'api', + errorMapping: { + rules: [{ + sourceCode: 429, + targetCode: 'RATE_LIMITED', + targetCategory: 'rate_limit', + severity: 'medium', + retryable: true, + userMessage: 'The payment provider is busy; try again shortly.', + }], + unmappedBehavior: 'generic_error', + }, + }], + }); + + // after — delete the key; there is no replacement because no error-mapping + // engine exists: a connector's failures reach callers as the provider's own + // errors (ADR-0097). A user-facing refusal text is the API error envelope's + // `userMessage`, declared by the code that throws — not connector metadata. + defineStack({ + connectors: [{ name: 'payments_api', label: 'Payments API', type: 'api' }], + }); + ``` + + One-line fix: delete the `errorMapping` block; `os migrate meta --from 17` + lists the mechanical edits for existing sources. + + The retirement kit: + + - `retiredKey()` tombstone on `ConnectorSchema.errorMapping` + (`packages/spec/src/integration/connector.zod.ts`; the section comment + records what the shape was), inherited by `DeclarativeConnectorEntrySchema` + - ADR-0087 registration: `integration/Connector:errorMapping` and + `integration/DeclarativeConnectorEntry:errorMapping` in + `RETIRED_KEYS_BY_MAJOR[18]`; `integration/ErrorMappingConfig`, + `integration/ErrorMappingRule`, `integration/ConnectorErrorCategory` in + `RETIRED_DEFS_BY_MAJOR[18]`; the D2 conversion + `connector-error-mapping-removed` (protocol 18) wired into the step-18 chain + — a pure lossless strip of the block from every `connectors[]` entry, one + notice per connector (the eleven nested keys leave with the block) + - no liveness-ledger row: `connector` is not an enrolled ledger type, so + there is no row to keep or drop + - pin tests (`connector.test.ts`): refusal pins asserting the issue path, + code and prescription on the base schema, the declarative entry, and the + `stack.connectors[]` authoring path; the tsc `never` channel; a + no-materialize pin; the conversion's strip and notice; zero holders of the + seven retired names on every public entry; the ADR-0087 registration + - generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, `api-surface/`, `json-schema.manifest/`, + `declaration-map/`, `export-origins/`, spec-changes, upgrade guide, + reference docs) + - zero authored occurrences in this repo's examples, skills and docs, and + zero hits in objectui at `0d8fd7c`, so no in-repo source changes ride along +- e89fa92: `IDataDriver` now declares `aggregate?` — the one engine-reached driver verb that had no signature to match against. + + The engine has always dispatched native aggregation by presence (`typeof driver.aggregate === 'function'`) and called `driver.aggregate(object, query, options)`, but the interface never spelled the member, so a custom driver's `aggregate` was checked in neither direction: swapped arguments or a non-row result compiled clean and surfaced only after the engine's `having` filter silently matched nothing. The member is declared optional, matching the presence test — a driver without native aggregation omits it and stays conformant, served by the `find()` + in-memory fallback. + + Additive: every in-repo driver already satisfies the declared signature (`(object: string, query: DriverQuery, options?: DriverOptions) => Promise[]>`); a wider parameter union or a looser return type stays assignable. What is newly refused is a wrong argument order or a non-array result. No `DriverCapabilities` bit is added — presence remains the capability test, as `data/driver.zod.ts` rules. +- e9fcd6b: feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) + + + + **BREAKING** — eight published duration keys are renamed and tombstoned. Shipped + as `minor` under the repo's launch-window convention for breaking changes; the + hand-migration prescriptions are registered under protocol major 18. Maintainer + ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in + the key NAME, never only in its `.describe()` prose, and grandfathers no existing + offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 + (#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 + (#15679) cleared `system/`. This card clears the remainder, and is the first + where the gate itself reads **`zero offenders`** and exits `0`. + + ⚠️ That is green **for the gate's currently declared population** + (`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has + already measured an offender outside this subtree, so the gate is expected to go + red again by design. This changeset does not claim #14478 is finished. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | + | `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | + | `ConnectorTrigger.interval` | `intervalSeconds` | seconds | + | `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | + | `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | + | `TursoConfig.timeout` | `timeoutMs` | milliseconds | + | `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | + | `ConversationAnalytics.duration` | `durationSeconds` | seconds | + + **Every value is unchanged** — only key names move. The two keys that carried a + default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to + 60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare + none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything + under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and + `TursoConfig.timeoutMs` still refuse a zero or negative integer, and + `ConversationAnalytics.durationSeconds` still refuses a negative length. Every old + spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site + (input type `never`) and fails the parse with the rename prescription rather than + a bare unrecognized-key error. + + `dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` + — were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming + the tombstone would have prescribed a key the shape refuses, which is the one + failure this rename could have introduced silently; a pin asserts all three. + + ## ⚠️ `dashboard.refreshInterval` crosses a repository boundary + + This is the only rename in the whole stack whose consumer is in **another + repository**, so its reader could not move in this PR the way every other reader + in this card did. objectui's dashboard renderer reads the key, multiplies by + 1000 to drive a `setInterval`, and republishes it as an authoring input the + console offers. Those sites move in a follow-up objectui card, sequenced behind + a release that actually ships this rename. + + Until that lands the renderer sees an absent key and simply does not start its + refresh timer — a dashboard still renders, and still refreshes when the user + asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards + and `os migrate meta` correct in the meantime. + + ## ⚠️ An eighth key moves that the gate did not list + + `AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` + named no unit at all, and the predicate judges prose against name. + + It moves anyway because it is not a second key. `persistence: { type: 'auto' }` + resolves to the same Node.js file adapter as `type: 'file'`, and this value is + forwarded to the same `FileSystemPersistenceAdapter` field, in the same + milliseconds, under the same `min(100)` bound. Renaming one arm and not the other + would have left one value with two spellings across sibling arms of one union, + and the driver reading both — the consumer-side dialect Prime Directive #12 + forbids. Its describe now names the unit too, and a pin asserts the refusal on + the arm the gate never listed, so a later reader cannot "restore" the bare + spelling as an over-application of the rule. + + ## Dispositions — four D2 conversions, two semantic entries + + Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and + unlike card 4/6 this card's answer is split. + + **D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` + are each a stack collection whose members are stored whole as `sys_metadata` + rows, so the conversion chain has a seam that sees them: + `dashboard-refresh-interval-to-refresh-interval-seconds`, + `connector-health-and-trigger-durations-unit-in-key` (both connector keys in one + pass, emitting separately), + `memory-persistence-auto-save-interval-to-ms` (both persistence arms) and + `turso-config-timeout-to-timeout-ms`. The two datasource conversions are + driver-aware for the reason `datasource-config-driver-key-aliases` records: a + bare `config.timeout` under another driver is that driver's own key and must not + be touched. + + **Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime + and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument + reached only through `AggregationPipeline.options`. Neither is a stack collection + member or a stored row, so the chain has no seam — the disposition every + runtime-emitted measurement in this stack has taken. + + All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + + ## A retirement tombstone is no longer read as a secret + + `refusedCredentialKeys` derives a driver's refused inline credentials by finding + `z.never()` keys in its config contract. A `retiredKey()` tombstone is also a + `z.never()`, and until this card no driver contract carried one — so "never ⇒ + credential" held by accident of population rather than by construction. The first + tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a + millisecond budget was a secret: it was redacted off the datasource read path and + dragged a non-credential name into the fallback list every unrecognised driver is + scrubbed by. + + The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` + itself stamps. The exclusion is deliberately **negative** — skip declared + tombstones — rather than positive (keep only keys marked `format: 'password'`), + even though every credential slot in every builtin contract does carry that + marker today: under-redacting is the dangerous direction, so a future credential + key whose author forgets the marker is still scrubbed, and only a key that has + explicitly declared itself retired may drop out. Both directions are pinned. + + ## Keys deliberately left alone + + `TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` + already carried their unit — they are the same-shape neighbours that made the + bare `timeout` and `monitoringWindow` collisions visible, and pins assert they + did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every + number on `ConversationAnalytics` other than the duration is a count of messages, + tokens or events: a count has no unit to carry. The turso schema shipped by + `@objectstack/driver-turso` is a separate declaration outside this gate's + declared population and is not touched here; card 6/6 owns it, so the two + declarations disagree by design until that lands. +- 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + + A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. + + - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. + - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. + + **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. + + An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. +- 6491463: `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + + **A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + + **The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + + **Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + + What to read instead, per case: + + - deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; + - asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + + Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + + New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. +- bca21f7: `POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. + + Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. + + `copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* + + - **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. + - **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. + + ⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. + + Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. +- e9fcd6b: feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.ttl` → `ttlSeconds`, `cache.databaseLoader.ttl` → `ttlMs`, tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) + + + + **BREAKING** rename of seven published authorable keys, shipped as `minor` under + the repo's launch-window convention for breaking changes; every rename is + registered under protocol major 18. Maintainer ruling 2026-09-02 on #14478 + (director decision batch #14, verbatim 「14461 你不处理,其他同意」): **ruled B** — + a spec-source gate for duration-shaped number keys **with no grandfathered + baseline**, plus an ADR-0087 conversion of every offender the ruling named, in + one PR, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」. + ⛔ No alias, no transition window: each old spelling is a `retiredKey()` + tombstone whose rejection names the new key. + + ## The defect + + `kernel/metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines + apart: `cache.ttl` in **seconds** (default 3600) and `cache.databaseLoader.ttl` + in **milliseconds** (default 60000). Both descriptions named their unit; the + key names did not. An author who copied the outer number into the inner block + got a 3.6-second cache and no error anywhere — the number was valid, the type + was right, the cache was simply cold. `hook.timeout`, `job.timeout` and + `DriverOptions.timeout` had the same shape (milliseconds, said only in prose) + beside siblings that spell theirs (`backoffMs`, `intervalMs`, the body-level + `timeoutMs`). The two tenant keys were worse for the reader who matters most: + `.describe()` is what `content/docs/references/**` publishes and the JSDoc above + a key is not, so `idleTimeout` / `sessionTimeout` said "in seconds" in a source + comment and published a bare `300` / `3600` to the reference page (#14519). + + ## FROM → TO + + | schema | before | after | value | + |:--|:--|:--|:--| + | `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) | + | `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) | + | `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) | + | `MetadataManagerConfigSchema` | `cache.ttl` | `cache.ttlSeconds` | unchanged (s, default 3600) | + | `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) | + | `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) | + | `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) | + + ```ts + // before + defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 }); + defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeout: 300000 }); + new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } }); + + // after — rename the key; the number is unchanged + defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 }); + defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 }); + new MetadataManager({ cache: { ttlSeconds: 3600, databaseLoader: { ttlMs: 60_000 } } }); + ``` + + **Migration.** Rename each key; no value changes. Authoring an old spelling + fails to compile (`tsc`: the input type is `never`) and fails to parse with a + prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a + mechanical D2 conversion (`hook-timeout-to-timeout-ms`, + `job-timeout-to-timeout-ms`, retired from the load path): run + `os migrate meta --from 17` to list the edits for existing sources and apply + them by hand; stored `sys_metadata` rows are rehydrated through the same chain. + The other five keys have no stack seam (runtime config, a per-call options + argument, cloud tenancy config) and carry a semantic entry each. The + `JobScheduleOptions` contract key that carries `job.timeoutMs` to the scheduler + is renamed in lockstep (`timeout` → `timeoutMs`), as is `DatabaseLoaderOptions.cache.ttl` → `ttlMs` in `@objectstack/metadata`. + + ## The gate + + `pnpm --filter @objectstack/spec check:duration-unit-keys` + (`packages/spec/scripts/check-duration-unit-keys.ts`, wired into `lint.yml`): + a property whose value is a `z.number()` / `z.int()` / `z.coerce.number()` + chain and whose `.describe()` names a time unit must carry that unit as a token + of its key name (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`, and the + knex-inherited `Millis`), and the token must agree with the prose — `ttlMs` + described "in seconds" is refused too. A `{ value, unit }` pair is recognised + by its sibling `unit` key; duration literals are strings and outside the + population. Calendar positions ("day of the month") and rates ("requests per + second") are skipped. There is no baseline and no `gen:`; a red is a rename + under an ADR-0087 conversion or a describe to fix. +- e9fcd6b: feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15676, ruling B on #14478) + + + + **BREAKING** — four published epoch-instant keys are renamed and tombstoned. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescription is registered under protocol major 18. + Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, because two sibling keys both spelled `ttl` in different units + are indistinguishable at the authoring site. Ruling B exempts two structural + classes from it, and is explicit about the mechanism: both are **declared on the + schema, never in a gate ledger**. This change lands both declarations and + applies them. + + ## 1. Epoch instants — the shared `EpochMs` schema + + `EpochMs` (`@objectstack/spec/shared`) is a `z.number().int()` describing + milliseconds since the Unix epoch. A key whose value IS that schema is an + INSTANT, and the gate recognises it structurally — nothing anywhere names the + exempt keys. + + An instant reads to the rule exactly like an offending duration (a bare name + plus a describe that says "milliseconds"), but renaming it the way the rule + prescribes would resolve the wrong confusion. Measured on this package's own + authorable surface: all 51 distinct keys ending in `Ms` are durations + (`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`) and all 51 distinct keys + ending in `At` are instants (`createdAt`, `expiresAt`, `lastUsedAt`). Spelling + an instant `*Ms` would move it INTO the family the rule exists to separate it + from. So the six instants take `EpochMs`, and the four whose name was bare take + the `*At` convention. + + ### FROM → TO + + | Schema | Wrote | Write instead | + | :-- | :-- | :-- | + | `api/WebSocketEvent` | `timestamp` | `occurredAt` | + | `api/SimplePresenceState` | `lastSeen` | `lastSeenAt` | + | `kernel/KernelContext` (and `TenantRuntimeContext`) | `startTime` | `startedAt` | + | `kernel/HealthStatus` | `timestamp` | `checkedAt` | + + ```ts + // before + const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startTime: Date.now(), features: {} }; + // after — the value is unchanged; only the key name and the declared schema move + const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startedAt: Date.now(), features: {} }; + ``` + + Each old key is tombstoned with `retiredKey()`, so it fails `tsc` at the + construction site and fails the parse with the rename prescription rather than + being silently stripped. `kernel/ServiceMetadata.registeredAt` and + `kernel/ScopeInfo.createdAt` were already correctly named and only change + schema — they are not retirements and need no edit. + + ⚠️ `api/PresenceState.lastSeen` (`api/realtime-shared.zod.ts`) is a **different** + key holding an ISO-8601 datetime string. It is untouched; do not rename it with + its neighbour. + + **One tightening.** `WebSocketEvent.timestamp` and `SimplePresenceState.lastSeen` + were declared bare `z.number()`, and `EpochMs` is `z.number().int()`, so a + fractional epoch that used to parse at those two sites is now refused. + `Date.now()` has always satisfied it. The other four already declared `.int()`. + + ## 2. External-standard mirrors — `.meta({ externalVocabulary })` + + A key whose name is fixed outside this repo carries + `.meta({ externalVocabulary: '' })`. The marker rides + `z.toJSONSchema` verbatim (the channel `xRef` / `xExpression` already use), the + gate honours it, and **the reference page publishes it**: the description cell + now reads `… in seconds (unit per HTTP Cache-Control \`max-age\` (RFC 9111 §5.2.2.1))`. + Publishing it is what makes the exemption honest — the gate exists because a + bare `maxAge` publishes a naked number to a reader who cannot see the source. + + Eleven keys are marked: the three HTTP `Cache-Control` directives, the two CORS + `Access-Control-Max-Age` config keys, the two S3 presigned-URL `expiresIn` keys, + the three better-auth forwarded options, PostgreSQL's `statement_timeout` and + the DNS record `ttl`. No authorable key is renamed or re-typed by this half. + + ⛔ Neither exemption is a pass on lying: a marked key still fails + `name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit + other than milliseconds fails the new `instant-unit-contradicts-schema`. +- ef3a138: feat(spec)!: an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention): on the schemas that type an EVALUATED expression + slot — today the `assignment` node's value envelope, + `AssignmentExpressionValueSchema` — an envelope with no `source` the engine can + evaluate is now **refused at authoring**, where it used to parse, register, + pass `objectstack validate`, and then fault at run time. + + Two spellings of one seam, refused by ONE rule with one message at `source` + (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`): + + ```yaml + assignments: + digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it + greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form of phase M9.1) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **`ExpressionSchema` is NOT narrowed.** It is the persistence contract — + `source` OR `ast` — and its docblock declares that `ast` becomes required in + build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and + its type `EvaluatedExpression`) is a sibling: the same envelope with `source` + required and non-blank, spelled once and composed by every evaluated slot, so + when AST-only evaluation lands the flip is one edit there rather than a + per-slot unwinding. The rule is worded as "an evaluated slot requires whatever + the engine can actually evaluate"; what that is today is `source`. + - **The notion of blank is the engine's own** — `.trim()`, which + `cel-engine.ts`'s helpers already apply — not a third one beside the shape + rule's `min(1)` and `validateExpression`'s trim. + - **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate` + and the runtime publish gate report a located `error` at the author's own + variable (`config.assignments..source`), and the executor's own shape + pass refuses the same set — all through the spec schema, so none of them + grew a rule of its own. + + **What an author does with a refused envelope.** An assignment value that + carried only `ast` has no evaluable form under M9.1: author its `source`. A + whitespace-only `source` was never an expression: delete the entry, or write + the expression. Every envelope with a non-blank `source` is unchanged, and + nothing is renamed, retired or rewritten — the refusal itself carries the + prescription. + + Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s + silent `false` on an envelope without a `source` — is a behaviour change on a + live path with its own card, and the edge-condition schema that carries that + envelope is narrowed in a follow-up once the in-flight change to + `automation/flow.zod.ts` lands. +- fa125f3: feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) + + **BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + The key is **already published, and published unenforced**. The version-packages + cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset + `field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which + declares `Field.valueDomain`, parses it, and refuses it on any type other than + `text` — and never reads it when a record is written. The 17.3.0 liveness ledger + states the gap in its own words: "a non-member WRITTEN to a `text` field + declaring a domain is accepted today". That write is accepted on 17.3.0 and is + refused from this release on. + + **Refused shape**, precisely: a record write that supplies a value for a `text` + field whose definition declares `valueDomain`, where the WRITTEN value is not a + member of the named standard. It fails with the field error code `value_domain`, + carrying `constraint: { valueDomain }` and a message that names the standard in + all four platform locales. Nothing else narrows — a field that declares no + `valueDomain` is untouched, and so is every other field type, because the schema + accepts the key on `text` alone and the validator judges exactly that set. + + **Remedy: write a member of the declared standard.** `iana_time_zone` admits + `UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses + `chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the + `valueDomain` declaration from the field lifts the refusal entirely, for an + author who declared a domain they did not mean. + + **No stored row is touched, and none becomes invalid.** This is the `min` / + `max` / `maxLength` transition-gate class: a value stored before the domain was + declared — or before this release — is never re-read, and it survives an edit of + another field on the same record. An absent or empty value follows the field's + `required` handling, not this check. + + + + - The membership test is the spec's shared `isValueDomainMember` — the same + predicate, over the same closed vocabulary, that a settings specifier's + `valueDomain` uses. A time zone accepted in Settings is the time zone + accepted in a field. + - The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain` + control, shown on exactly the types the schema accepts the key on. The + object-form control's choices are derived from the vocabulary, not re-typed. +- a646120: `FILTER_TEXT_CASES` declares what a text operator answers over a stored value that is NOT a string, and the fixture gains its first non-string column. + + Measured before this row existed, one filter over one numeric column answered four ways across the platform: `driver-memory`'s reference matcher said NO to `$contains` and to `$notContains` for the same row; its live mingo path, `formula`, objectql's `having`, `driver-mongodb` and the analytics face type-gated (`$contains` NO, `$notContains` YES); the SQLite family coerced the number to text in its storage class's spelling (REAL renders `5` as `'5.0'`); and live Postgres refused at query time with SQLSTATE 42883 — a 500. + + The maintainer ruled the cell on 2026-09-05 (option A, type-gate): a stored value that is not a string never satisfies a positive text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` — complementarity holds, on every face. Coercion was refused on the measurement; a declared-type door that refuses the filter before any backend runs is deferred to its own decision card, not rejected. + + - `FilterTextRow` is now `{ id, name, score }` — `score` is a NUMBER on every row (a `0` among them), chosen so a coercing backend answers a visibly non-empty set and a truthiness guard drops a row. + - Five new evaluated rows over `score`: the four positive operators the table can carry answer `[]`, `$notContains` answers all nine. (`$like` / `$ilike` follow the same rule and are pinned on the faces that answer them — the table is a driver's enrolment and `driver-mongodb` refuses those two.) + - `NON_TEXT_STORED_VALUE_TYPES` (`field-value.zod.ts`) — the numeric and boolean value classes, i.e. the declared field types whose stored value is never text — is the list the SQL faces classify a column by at compile time, since they cannot read the value. Temporal types are deliberately absent: their stored form is a dialect question (ADR-0053) the row does not decide. + + Every suite that materialises the fixture adds the column (SQL `initObjects` DDL included). +- 6f1ce7d: feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661) + + + + **BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny. + + The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares. + + New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes. + + The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set. + + What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant. +- 7778115: `ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. + + `find()` is declared `Promise`, but on the hook path it returned `hookContext.result` with nothing re-checking the value after the `afterFind` dispatch. A handler assigning `ctx.result = { records: [ … ] }` therefore made a read declared to resolve to an array resolve to an envelope instead — silently, with no throw, no diagnostic and no log, while roughly 140 call sites read the answer as an array on the strength of the declaration. + + The engine now refuses that, immediately after the `afterFind` dispatch and ahead of the two consumers that already assume the array (secret-field masking and the `__search` companion strip). The refusal is a named error, `FindHookResultNotArrayError`, carrying the registered ADR-0112 code `FIND_HOOK_RESULT_NOT_ARRAY` and HTTP `500`; its message names the hook event and the object, and `developerMessage` carries the remedy. + + **Shaping stays legal, and nothing about it changes.** A handler may still mutate rows in place, delete keys, filter rows out, or assign a *different array* built from them — `Array.isArray` is the whole predicate, deliberately, so that `ctx.result = ctx.result.map(…)` keeps working. Only the container is protected. + + What to do if this refusal fires: + + - to answer no rows, assign `[]`; + - to refuse the read, `throw` from the handler — the supported way for any hook guard to say no; + - to hand a caller a different structure, build it in the caller, not in the hook. + + `@objectstack/spec` widens by one member: `FIND_HOOK_RESULT_NOT_ARRAY` joins `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the generated `ErrorCode` union — and therefore `ApiErrorSchema.code` — accepts it. Additive: no existing code is removed or renamed. + + Scope: this closes the one `return hookContext.result` site in the engine with a concrete declared shape to violate. `findOne`, `update` and `delete` declare `Promise` and carry no enforceable declaration; that is a separate question about those declarations and is deliberately not answered here. +- 52804cd: feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964) + + + + **BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]` + carries two edges with the same `id` is now **refused at parse time** — by + `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a + flow through the schema (`objectstack validate`, the runtime publish gate, a + stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under + the repo's launch-window convention for breaking changes. Maintainer ruling + 2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option + A — an `error`, not a `warning`; no opt-out, no transition window. + + Every reader of an edge id assumes the ids in a flow are unique — a designer, + a BPMN export, a flow diff, any traversal that dedupes by id — and nothing + enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped + through two releases of green CI in a downstream app and was inert only + because the engine keys out-edges by `source`, never by `id`: the collision is + invisible until something keys on ids, and then silently wrong rather than + loudly broken. The id space is hand-authored, so the next author picking a + "free" id from the sequence had no way to know it was taken. + + **What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine` + on the flow's `edges[]`. Each later occurrence of an already-declared id raises + one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming + both positions, so the formatted error points at the edge to renumber: + + ```text + ✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: … + ``` + + **What does NOT change:** `edges[].id` keeps its name, type and describe; the + node vocabulary, the edge `type` enum and every other refusal are untouched; + a flow with unique edge ids (or no edges) parses exactly as before. Node ids + are not covered by this change. + + The shape that is refused, and what the author does about it — a two-edge + excerpt, the later edge renumbered: + + ```ts + // before — parsed on green, both edges keyed 'e20' + edges: [ + { id: 'e20', source: 'qualify', target: 'convert' }, + { id: 'e20', source: 'convert', target: 'end' }, + ] + + // after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one: + edges: [ + { id: 'e20', source: 'qualify', target: 'convert' }, + { id: 'e21', source: 'convert', target: 'end' }, + ] + ``` + + **Remedy.** Renumber the later edge to an id no other edge in that flow + carries; nothing else in the flow needs to move. The census over this + repository found no flow to migrate, so this is a release note, not a + migration: no shipped example, fixture or seed in `packages/**` or + `examples/**` declares a duplicate edge id, and the pinned objectui tree + carries none in its authored flows. The one known downstream instance was + renumbered before this change (hotcrm PR #1571). +- 3f89967: A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`. + + Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type. + + The contract, declared here first (the engine and runner halves follow in their own packages): + + - **`end` node config** — `EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was. + - **Run row** — `ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run. + - **Result / wire** — `AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast. + + Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before. +- 53cf263: feat(spec)!: `FlowSchema` refuses a flow whose top-level `nodes[]` declares the same id twice (#15713) + + + + **BREAKING** accept-set narrowing on `FlowSchema` — a flow whose top-level + `nodes[]` carries two nodes with the same `id` is now **refused at parse time** + — by `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that + validates a flow through the schema (`objectstack validate`, the runtime + publish gate, a stack's `flows[]`) — where it used to parse on green. Shipped + as `minor` under the repo's launch-window convention for breaking changes. The + exact parallel of #14964 (edge ids, maintainer ruling 2026-09-05, option A — + an `error`, not a `warning`; no opt-out, no transition window), applied to the + other hand-authored id space in the same schema. + + Every edge's `source` / `target` names a node by id, and the engine's traversal + picks out-edges by `source` — with two nodes sharing an id, every edge from + that id is ambiguous and whichever node wins is decided by array order, + silently. A designer, a BPMN export and a flow diff key on node ids the same + way they key on edge ids. Only region bodies (`loop` / `try_catch` / `parallel` + sub-graphs) were checked, by `analyzeRegion` at `registerFlow()`; the flow's + own top-level `nodes[]` parsed with the collision intact — measured on + `origin/main` `1f2a02ba` with two lit controls on the same schema instance (a + node missing its `label` → refused at `nodes.1.label`; an unknown key on a node + → `unrecognized_keys`). + + **What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing + `superRefine` on `FlowSchema` gains a pass over the top-level `nodes[]`, the + same shape as the `edges[]` pass. Each later occurrence of an already-declared + id raises one `custom` issue, anchored at `nodes[N].id` of the *later* node and + naming both positions, so the formatted error points at the node to rename: + + ```text + ✗ nodes.2.id: Duplicate node id `n` — `nodes[2]` reuses the id already declared by `nodes[1]`; every node id in a flow must be unique. Rename one of them: … + ``` + + **What does NOT change:** `nodes[].id` keeps its name, type and describe; the + open node-type vocabulary (ADR-0018), the region rules (`analyzeRegion`) and + every other refusal are untouched; a flow with unique top-level node ids + parses exactly as before. The rule judges the flow's **own top-level** + `nodes[]` only — a region body's nodes remain `analyzeRegion`'s to judge, and + whether a region node may reuse a top-level node id (one id space or two) is a + separate decision (#16134) this change neither takes nor pre-empts. + + The shape that is refused, and what the author does about it — a four-node + excerpt, the later node renamed and its edge re-pointed: + + ```ts + // before — parsed on green, two nodes keyed 'n' + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', type: 'assignment', label: 'Assign A' }, + { id: 'n', type: 'assignment', label: 'Assign B' }, + { id: 'end', type: 'end', label: 'End' }, + ] + + // after — refused at parse (nodes.2.id: Duplicate node id `n` …); rename the later one + // and point the edges that meant it at the new id: + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', type: 'assignment', label: 'Assign A' }, + { id: 'n2', type: 'assignment', label: 'Assign B' }, + { id: 'end', type: 'end', label: 'End' }, + ] + ``` + + **Remedy.** Rename the later node to an id no other top-level node in that + flow carries, then re-point at the new id the edges whose `source` / `target` + meant that node; nothing else in the flow needs to move. The census over this + repository found no flow to migrate, so this is a release note, not a + migration: no shipped example, fixture or seed in `packages/**` or + `examples/**` declares a duplicate top-level node id. +- 9c270bb: feat(spec)!: `GanttConfigSchema` / `TreeConfigSchema` refuse undeclared keys — both `.passthrough()` windows are closed and the ten gantt members plugin-gantt read through the window are declared (#15469) + + + + **BREAKING** accept-set narrowing on two published authorable config blocks — + `ListView.gantt` (`GanttConfigSchema`) and `ListView.tree` (`TreeConfigSchema`) + in `@objectstack/spec/ui`, reached through every view door (`defineView`, + `objects[].listViews`, the `view` metadata type): an UNDECLARED key inside + either block is now **refused** at parse with the `strictObject` named error + (`unrecognized_keys`; surface named, key echoed, closest declared key + suggested), where it used to pass through silently. Shipped as `minor` under + the repo's launch-window convention for breaking changes. Maintainer ruling + 2026-09-05 on #15469 (director decision batch #41 item 2, verbatim 「同意」): + option A for both sites. + + Both blocks were `strictObject(…).passthrough()` — the campaign's own helper + applied and immediately undone, so `colourField` on a gantt block parsed green + and rendered an uncoloured bar while the same typo on a calendar or timeline + block got a named refusal. One `strictObject` applied and then undone is two + contracts on one surface (Prime Directive #12); the renderer-ahead window it + kept open is shut, and a renderer knob is declared in the spec before it is + read. + + **Newly declared on `GanttConfigSchema`** — all optional, types measured from + objectui's `GanttConfigExtensionFields` (`@object-ui/types/zod`) at pin + `a472b07`, each with a describe saying what plugin-gantt does with it: + + - `borderColorField: string` — field carrying a per-task alert stroke color + - `lockField: string` — field marking a row view-only (truthy = locked) + - `objectField: string` — field carrying the row's own object API name (mixed-object trees) + - `summaryExtent: 'children' | 'self'` — how a summary bar's span is computed + - `defaultCollapsedDepth: integer ≥ 0` — auto-collapse nodes at or below this depth + - `dependencyTypes: boolean` — whether the store persists dependency link types + - `timeZone: string` — IANA business time zone the calendar renders in + - `exportFileName: string` — base name for exported PNG / PDF files + - `interactions: { move?, resize?, progress?, link? : boolean }` — per-interaction switches (closed sub-object) + - `timeSegments: { dayStart?: string, bands: [{ key?, label, start, end, color? }], showMidnight?: boolean }` — shift segmentation for the day-mode timeline (closed sub-objects) + + **`TreeConfigSchema` declares nothing new.** plugin-tree's `getTreeConfig` + (objectui `a472b07`) reads exactly the four keys already declared — + `parentField`, `labelField`, `fields`, `defaultExpandedDepth` — from the `tree` + block, so the close refuses only what no renderer ever read. + + **Who is affected (measured, objectstack `f7db8f4fd`):** zero gantt or tree + blocks under `examples/**`, `content/docs/**`, `skills/**` or any package + fixture author one of the ten keys or any undeclared key; objectui's own gantt + fixtures author the ten and keep parsing because the keys are now declared. A + block carrying a key outside the declared set — a misspelling such as + `colourField`, or a renderer knob authored ahead of its declaration — is refused + on upgrade with the key named; fix the spelling, or declare the knob in the spec + first. +- a84e1ce: feat(spec): `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882) + + `ResolveOptions.fallbackChain` on the `@objectstack/spec/system` label + resolvers (`translateMetadataDocument`, `translateObject`, `translateApp`, + `resolveViewLabel`, …) is the ordered list of locales consulted after the + requested one and BEFORE the authored label. Nothing on `II18nService` + exposed the deployment's declared fallback (`i18n.fallbackLocale`, else + `defaultLocale`), so no serving layer could thread it, and every caller fell + to the resolver's literal `['en']` default. A `zh-CN` workspace that shipped a + courtesy `en` bundle therefore served English bundle text to a `zh-CN` + request ahead of its own authored Chinese labels. + + - New optional contract member `II18nService.getFallbackLocale?(): string | undefined` + — the locale the service's own `t()` consults second. `undefined` (or the + method absent) means nothing was declared, and a serving layer must then + leave the resolver's default in place rather than invent a chain. + - The `fallbackChain` documentation now states who supplies it (the serving + layer, from `getFallbackLocale()`) and that the `['en']` default applies + only when a caller declares no chain at all. The resolver's behaviour for + a caller that passes nothing is unchanged. + + Additive: no existing implementation or caller changes shape. +- bf1054a: feat(spec): retire the fourteen inert deadline keys of the incident-response, training and change-management schemas (#14477, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescriptions are + registered under protocol major 18, where `os migrate meta` users will look). + Maintainer ruling 2026-09-02 on the census card (ruled A: retire per family): + ADR-0049 enforce-or-remove decides it — declared-but-unenforced deadline + surface with zero measured readers comes off. + + Fourteen hour/minute/day-shaped deadline, SLA and duration key sites — twelve + distinct names, because `durationMinutes` and `estimatedMinutes` each occur at + two sites — sat on the exported incident-response, training and + change-management schemas and in the generated reference docs, and **nothing + read them**: the schemas are exported from `@objectstack/spec/system`, mounted + by no stack key, registered as no metadata type, absent from the 2026-06 + liveness ledgers, and the reader census over every package outside + `packages/spec` (tests and changelogs excluded) and over objectui at the + pinned sha returned zero hits for every key. An author could write + `triageDeadlineHours: 4`, `validityDays: 365` or `regulatorDeadlineHours: 72` + and reasonably expect the platform to escalate, expire or notify — it never + did, and it never said so. Six of the keys carried defaults (30 minutes, + 1 hour, 2555 days; 365, 30 and 14 days) that were materialized into every + parsed document without ever being consulted. A compliance-shaped deadline + that fails silently is the worst form of the shape ADR-0049 names. + + **What is refused:** authoring any of the keys below, with any value, on the + base schema and through every carrier that nests it (`Incident.responsePhases[]`, + `IncidentResponsePolicy.notificationMatrix`, `TrainingPlan.courses[]`, + `ChangeRequest.impact` / `.rollbackPlan` / `.implementation`). None of the + schemas is `.strict()`, so each key is a `retiredKey()` tombstone rather than a + bare deletion (a deletion would have stripped it in silence): authoring it is a + `tsc` error (`never`) and a parse error carrying the prescription + (`invalid_type` at the path of the key). + + | schema | retired keys | + |:--|:--| + | `IncidentResponsePhase` | `targetHours` | + | `IncidentNotificationRule` | `withinMinutes`, `regulatorDeadlineHours` | + | `IncidentNotificationMatrix` | `escalationTimeoutMinutes` (default 30) | + | `IncidentResponsePolicy` | `triageDeadlineHours` (default 1), `retentionDays` (default 2555) | + | `TrainingCourse` | `durationMinutes`, `validityDays` | + | `TrainingPlan` | `recertificationIntervalDays` (default 365), `gracePeriodDays` (default 30), `reminderDaysBefore` (default 14) | + | `ChangeImpact` | `downtime.durationMinutes` | + | `RollbackPlan` | `steps[].estimatedMinutes` | + | `ChangeRequest` | `implementation.steps[].estimatedMinutes` | + + **What stays, byte-identical:** every other key of the three families with its + default and its (absent) readers, and every export — no def leaves the public + surface. Parsed documents no longer carry the six former defaults. + + **Held, not touched:** the `ESignatureConfig` pair (`expirationDays`, + `reminderDays` in `data/document.zod.ts`) — the ruling left that branch open + pending the e-signature roadmap answer; it stays on the card. + + ## FROM → TO + + ```ts + // before — parsed green; no engine ever read a single one of these numbers + const policy: IncidentResponsePolicy = { + notificationMatrix: { + rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], + withinMinutes: 15, notifyRegulators: true, regulatorDeadlineHours: 72 }], + escalationTimeoutMinutes: 45, + }, + defaultResponseTeam: 'security_team', + triageDeadlineHours: 2, + retentionDays: 3650, + }; + const course: TrainingCourse = { + id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', + category: 'security_awareness', targetRoles: ['all_employees'], + durationMinutes: 60, validityDays: 365, + }; + const rollback: RollbackPlan = { + description: 'Restore from backup', + steps: [{ order: 1, description: 'Restore backup', estimatedMinutes: 15 }], + }; + + // after — delete the keys; there is no replacement because no incident-response, + // training-management or change-management engine exists to keep a deadline. + // Record retention is the object-level `lifecycle` block (ADR-0057), declared on + // the object that stores the records. + const policy: IncidentResponsePolicy = { + notificationMatrix: { + rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], + notifyRegulators: true }], + }, + defaultResponseTeam: 'security_team', + }; + const course: TrainingCourse = { + id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', + category: 'security_awareness', targetRoles: ['all_employees'], + }; + const rollback: RollbackPlan = { + description: 'Restore from backup', + steps: [{ order: 1, description: 'Restore backup' }], + }; + ``` + + One-line fix: delete the key wherever it is authored. There is no + `os migrate meta` edit list for these keys — none of the schemas is a stack + collection member, so the conversion chain has no seam to walk (the + `MetadataPluginConfig.additionalTypes` precedent); the tombstone prescription + and the protocol-18 upgrade guide are the channels. + + The retirement kit: + + - `retiredKey()` tombstones at all fourteen sites (`packages/spec/src/system/ + incident-response.zod.ts`, `training.zod.ts`, `change-management.zod.ts`; + each file's section comment records what the shape was and why no D2 + conversion exists) + - ADR-0087 registration: fourteen `RETIRED_KEYS_BY_MAJOR[18]` entries (the + three nested change-management sites spelled `ChangeImpact:downtime.durationMinutes`, + `RollbackPlan:steps.estimatedMinutes`, `ChangeRequest:implementation.steps.estimatedMinutes`) + and three D3 semantic entries, one per family + - no liveness-ledger row: none of the three families is an enrolled ledger + type, so there is no row to keep or drop + - pin tests (`deadline-keys-retirement.test.ts`): a refusal pin per site + asserting the issue path, code and prescription on the base schema and + through the nesting carriers; the tsc `never` channel; no-materialize pins + for the six former defaults; the ADR-0087 registration; and a tree-scoped + absence pin over every authored source in the repo + - generated baselines and docs follow the schema: `authorable-surface/` gains + eleven `[RETIRED]` rows, `authorable-defaults/` loses six rows, the three + system reference pages are regenerated, and the gitignored `json-schema/` + output is re-emitted on the next build + - `json-schema.manifest/` is unchanged, and correctly so: it ratchets def + *names*, and retiring keys removes no def from the published surface + - `spec-changes.json` and the protocol upgrade guide are unchanged too: both + project the migration chain at the current protocol major (17), so these + protocol-18 registrations reach them at the 18 cut + - zero authored occurrences in this repo's examples, skills and hand-written + docs, and zero hits in objectui at the pinned sha, so no in-repo source + changes ride along beyond the three families' own unit tests +- 222dc0f: feat(spec): `IJobService.replay` gains an optional third argument, `options?: JobReplayOptions`, carrying `force: true` (#14766 — the contract half of the #14501 A+a2 ruling) + + Additive: the argument is optional, an existing two-argument `replay(name, data?)` implementation keeps compiling and behaving as before, and omitting it is the pre-#14766 call exactly. `JobReplayOptions` is exported from `@objectstack/spec` (`contracts`), with one member, `force?: boolean`. + + **What the contract now declares** (`packages/spec/src/contracts/job-service.ts`, the `replay` TSDoc), for a scheduled (cron) flow whose tick window takes a `(flow, tick-window)` dispatch claim in `sys_flow_dispatch`: + + - `replay(name, data)` on a window whose claim is **absent or failed** re-runs the window — unchanged behaviour, and every job that never takes a claim is this row; + - `replay(name, data)` on a window whose claim **succeeded** is **refused loudly**: the promise rejects with an ADR-0112 envelope — `code: 'RESOURCE_CONFLICT'` (the standard-catalog member HTTP 409 derives; no new extension code) and `status: 409` — whose message names the window asked for and the claim that refused it. Never a silent no-op; + - `replay(name, data, { force: true })` sends anyway; the duplicate is the operator's, taken knowingly. + + **Declared here, enforced by #14501.** This release changes the contract text and the signature only. The refusal semantics are implemented by the services half (#14501: the `(flow, tick-window)` claim through `sys_flow_dispatch`, and `DbJobAdapter.replay` reading it); until that lands, shipped adapters still accept the third argument and ignore it, re-running the window as before. A third-party `IJobService` implementation that already declares `replay` needs no change to keep compiling; one that wants the once-only guarantee implements the table above. +- e9fcd6b: feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) + + + + **BREAKING** — fourteen published `kernel/` duration keys are renamed and + tombstoned. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescriptions are registered under protocol + major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, + 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. + Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, + and the whole-tree count falls **36 → 22**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `EventPersistence.retention` | `retentionDays` | days | + | `EventSourcingConfig.retention` | `retentionDays` | days | + | `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | + | `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | + | `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | + | `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | + | `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | + | `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | + | `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | + | `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | + | `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | + | `StartupOptions.timeout` | `timeoutMs` | milliseconds | + | `PluginStartupResult.duration` | `durationMs` | milliseconds | + | `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | + + **Every value is unchanged** — only key names move, and every default moves with + its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to + 365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the + authoring site (input type `never`) and fails the parse with the rename + prescription rather than a bare unrecognized-key error. + + ## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace + + **`responseTime` meant two different units on two kernel shapes.** On + `PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a + publisher promises to answer a vulnerability report); on + `PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So + `responseTime: 24` was a day on one shape and a fortieth of a second on the + other, with nothing at the authoring site to tell them apart. They land on + `responseTimeHours` and `responseTimeMs` respectively — do not let one + find-and-replace rewrite both. + + **`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision + was already costing prose: the protocol lifecycle page carried a standing + paragraph whose only job was telling the two apart. `metrics.uptime` becomes + `metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a + separate, unchanged surface and must not be renamed with it. + + A third split worth reading before you migrate: `estimatedDurationSeconds: 120` + is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent + measurements of the same package install carried two different units, and no + parse can catch a value moved between them — both bounds accept any + non-negative integer. + + ## Dispositions — five semantic entries, no D2 conversion + + Justified per key rather than defaulted, and this card's answer is uniform: + **none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs + over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, + `upgrade` or plugin-security root — none of these twelve defs is a stack + collection member or a registered metadata kind stored as a `sys_metadata` row, + so the conversion chain has no seam that would see one. They are host + construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, + `MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and + runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, + `StartupOrchestrationResult`, `UpgradePlan`, + `PackageDependencyResolutionResult`). Each therefore carries a **semantic** + entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on + one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling + B prescribes for a key that is not authorable metadata. All fourteen are + registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + + ## Keys deliberately left alone + + `EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and + `MultiVersionSupport.rollout.percentage` is a proportion — neither is a + duration, so neither has a unit to carry and both keep their names. + `RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above + the key ("Execution timeout in milliseconds"), a channel + `check:duration-unit-keys` does not read: it reads `.describe()` and + `.meta({ description })`, and this key's describe ("Maximum execution time") + names none. The gate therefore lists it among the duration-shaped keys but + deliberately does not judge it — neither an offender nor an exemption — so it is + outside this rename; that JSDoc-channel gap is filed as #15939. A pin test + asserts the key still parses bare, so a later sweep cannot read the four + security renames as "every timeout on that file". + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - + startTime`), the kernel and contracts test suites, and the hand-written + `content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now + states the collision the rename removes. + + ⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local + `PluginStartupResult` interface — a different type, carrying `startTime` rather + than any duration key. It is not a reader of this schema, it is untouched by + this rename, and the divergence between the two shapes is tracked separately. +- f9a3c32: feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + + `BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + + Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + + - **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. + - **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. + - **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + + What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. +- f502898: feat(spec): list-view grouping is server-side — the group header query and the per-group row page compile from the view (#14556) + + Maintainer ruling A on objectui#7189 (2026-09-02): grouping on a list view is + server-side. The set of groups and every number in a group header — the count + and any per-group aggregation — are properties of the query, not of the fetched + page; rows inside a group are paged. Grouping one fetched window (the interim + behaviour) rendered two headers (86, 14) or five (31/31/30/7/1) for the same + 186 rows in five units depending on row order, and left the rows past the + first window unreachable. + + The contract reuses the query shapes the platform already has — no new query + shape, no new engine verb, no new envelope: + + 1. **The group keys and every header number are ONE aggregate query** + (`EngineAggregateOptions`, executed by `IDataEngine.aggregate`): `groupBy` + is `grouping.fields[].field` in nesting order (a multi-level grouping is a + multi-column `groupBy`), `aggregations` is a `count` node (the group's total + row count, alias `count`) plus the view's declared column summaries mapped + onto `AggregationFunction` — the one aggregation vocabulary datasets already + use — and `where` is the view's composed filter. + 2. **The rows inside a group are the existing paged `find`** + (`EngineQueryOptions`) with the group's key predicate AND-ed into the view + filter, `limit` / `offset` per group. + + New on the `ui` entry, `view-grouping-query.ts`: + + - `compileListViewGroupQuery(view, { where?, depth? })` → the header query; + `compileListViewGroupRowsQuery(view, groupKey, { where?, limit?, offset?, orderBy?, fields? })` + → the row page; `listViewGroupKeyPredicate` (the empty group is spelled with + the `$null` predicate — the spelling the view filter dialect's `is_empty` + lowers to). + - `COLUMN_SUMMARY_AGGREGATION` — the `ColumnSummary` → aggregation table, + exhaustive by type: `count` → a fieldless `count` (`COUNT(*)`), + `count_unique` → `count_distinct`, `sum` / `avg` / `min` / `max` → the same + name, `none` → nothing; `count_filled` / `count_empty` / `percent_filled` / + `percent_empty` map by derivation — one `{ function: 'count', field }` node + (`COUNT(field)`, the non-null count, header column `count_`), from + which `deriveColumnSummary(row, summary, field)` computes all four on the + header row (`count_filled` = `count_`, `count_empty` = `count − + count_`, `percent_filled` = `count_ / count`, 0 when the count + is 0, `percent_empty` = `1 − percent_filled`). Server-side "empty" is `null` + on every face; the footer's client-side reading of `''` / `[]` as empty is + the renderer's to converge. A future member with no counterpart is refused + loudly at compile time (`ListViewGroupQueryError`, `NOT_IMPLEMENTED` / 501, + the summary's path — `UNMAPPED_COLUMN_SUMMARIES`, empty today); a value that + is no member at all is `INVALID_QUERY` / 400. + - Result-column naming on a header row: each grouped field under its own name + (raw stored value, `null` for the empty group; group keys are scalar), `count`, + and each summary under `_` (`columnSummaryAlias`). + + `GroupingConfigSchema` / `GroupingFieldSchema` / `ColumnSummarySchema` now say + this in their docs, with the shape's recorded limits (a date grouping field + groups per distinct stored instant; header cardinality is unbounded). Nothing + changes in what parses: no key is added, removed or re-shaped. `minor` because + a new exported helper and a declared contract semantics ship; not breaking — + the page-scoped behaviour was never declared. Both queries ride the existing + `POST /data/:object/query` door (`protocol.findData` → `engine.aggregate`, + answering `{ object, records, total, hasMore }`); the grid consuming the header + rows is objectui#7189. +- cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + + `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. + + The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. + + What changed: + + - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. + - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. + - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. + + A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. + + `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. +- a7da4de: feat(spec): `adr-0030-notification-event` joins `CREATION_ATTESTED_MIGRATION_IDS`, and its docblock states what a run may claim in the `sys_migration` ledger (maintainer ruling 2026-09-05 on #15710) + + The ADR-0030 notification-convergence migration id was registered so that + "has this cut-over run here?" is answerable at all, with its ledger semantics + deliberately left open on the constant. The maintainer has now ruled them + (decision batch #47 item 5, verbatim 「同意」 — the question batch #21 reserved), + and this release lands the spec half: + + - **Creation-attested.** A datastore created after the cut-over has no legacy + `sys_notification` inbox rows by construction, so the id is now a member of + `CREATION_ATTESTED_MIGRATION_IDS`. A store created from empty on this release + therefore carries a third attestation row in `sys_migration` at boot, in the + same uniform shape as the two ADR-0104 rows (`details.attested: + 'datastore-created-empty'`, `applied_at: null`, `blocking: 0`, `verified_at` + set for the fact observed at birth). Existing stores are untouched: + `attestFreshDatastore` writes only on a store it observed being created and + never overwrites a row, so a store created before this release attests + nothing new — its row for this id arrives with the first run of the migration. + - **The ledger-claim matrix**, on the constant's docblock, replacing the + registration-era "silence is not an answer": `last_run_at` on every completed + non-`error` run (`migrated`, `already_done`, `not_applicable`); `applied_at` + only on `migrated`; `verified_at` never set by a run (the migration has no + self-check, and `verified_at` means one passed); `blocking: 0`; + `details.outcome` carries the four-valued result; an `error` run writes no + claim at all. + - **Receipt, not gate.** Nothing reads the row as a precondition, and nothing + may: it is what an operator reads, in the shape the seed-tenancy repair + already uses (`verified_at: null`, `blocking: 0`), which + `isDataMigrationFlagVerified` answers `false` to by design. + + Additive: no authorable key, export or accept-set narrows, so no BREAKING + banner applies. Which caller writes the run receipt when the migration runs is + the runner's own contract (`@objectstack/metadata/migrations`) and lands + separately. +- 4db3c61: `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + + `isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + + Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + + Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. +- 414c1fc: feat(spec)!: `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B) + + + + **BREAKING** accept-set change on one props-map entry, shipped as `minor` under + the repo's launch-window convention for breaking changes; the migration + prescription is registered under protocol major 18. + + One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, + verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2), + `element:record_picker`'s `filter` was the one `filter` input in + `ComponentPropsMap` still declared as the MongoDB-style record + (`FilterConditionSchema`) while the three array-declared siblings + (`record:related_list`, its nested Add-affordance picker, `element:number`) + declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare + `filter` as `z.unknown()`, #15449 — so the filter a list view stores and + renders was refused by the picker beside it. The entry now declares the same + array form those siblings do, and the `FilterConditionSchema` import that existed for this + one site leaves the file with it. + + Sequenced measurement-first, as that convergence had to be: the `record_picker` + read path was measured at the objectui pin before the declaration moved. The + renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose + `convertQueryParams` lowers a rule array through `translateFilterArray` into + filter AST tuples — the door every list view's stored rule array already takes + — and nothing on that path parses `properties` against the installed spec. + + **Migration** (`element-record-picker-filter-rule-array` — listed by + `os migrate meta --from 17` once the protocol major is 18): a record-form `filter: { status: 'active' }` becomes + `filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an operator + object `{ amount: { $gt: 100 } }` becomes + `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys + become several rules (they AND). The record form is refused at `filter` + (`invalid_type`, expected array). The binding-level `dataSource.filter` on the + same node is a different key and is unchanged by this release. + + `ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed + state now differs from its authored state on `filter` (`operator` normalizes on + parse), so the bare alias is no longer isomorphic. +- 8e0b297: fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) + + + + **BREAKING** meaning change on a published payload — `user.positions` in + `GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's + launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on + #15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one + name, one meaning. + + `customSession` built the array from the better-auth `sys_user.role` scalar + split on commas, plus the active membership mapped to `org_*`, plus + `platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 + D4 table that is the source of truth for custom positions. The Console binds + that array straight through as the CEL root `current_user`, so an + `action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed + by a business position answered FALSE for **everyone**, including the user who + genuinely held it. + + ⭐ It failed **silently and in the invisible direction**: the root was bound and + the key was present, so `has(current_user.positions)` was true, CEL raised + nothing, and the predicate simply returned FALSE. A predicate that *faults* + fails OPEN in the shell and would have shown the button; a successful FALSE + shows nothing and reports nothing. The documented example + (`'org_admin' in current_user.positions`) kept working throughout, because + `org_admin` is the one name that sits on **both** axes. + + This was a **declared** contract being violated, not an ambiguous name: + `EvalUserSchema` already specified `positions` as "built-in identity names + + position names", exposed to "every predicate surface (server formula, server + RLS, client UI gates) ... with an identical shape" so that a predicate + "evaluates identically wherever it is written". `/auth/me/permissions` and + every server-side evaluator (`ExecutionContext.positions`) already resolved the + security axis; only the session payload did not. + + **What changes** + + - `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, + not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE + authority (`core/security/resolve-authz-context.ts`, whose header forbids + every entry point from re-reading the `sys_*` grant tables itself), scoped to + the session's active organization. The payload therefore carries the + `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and + agrees with `/auth/me/permissions` set for set. Same move + `isPlatformAdminUserId` made at #10348. + - `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as + an alias of `'platform_admin' in positions`), so one authority answers both. + - `packages/spec` — `EvalUserSchema` states which axis `positions` is, and + states that the better-auth role scalar is not it. + + **No key is renamed, and none is added.** The ruling anticipated a renamed + auth-role array; measured against the tree, it has no content to carry and no + consumer. Everything the old union contributed beyond the security axis was the + `sys_user.role` scalar's own tokens — and that scalar is **already published, + unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban + carves out, for third-party schema this platform does not own). Minting a + `roles` array would revive that banned word to publish information the payload + already carries. (Precisely: `check:role-word` ratchets the reserved word in + `content/docs` and `skills/` PROSE, while the identifier ban over authored + metadata lives in `packages/lint`; a TypeScript payload key trips neither + mechanically until it is documented. The ADR-level prohibition is what rules + here, not a gate that would have caught it.) A consumer that wants the + better-auth role reads `user.role`. + + **What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); + `platform_admin` still derives from the unscoped `admin_full_access` grant with + its ADR-0091 validity window and ADR-0049 active flag intact — + `platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over + those shapes. + + ⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** + `positions.includes('platform_admin')` is the form + `resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` + row may spell that very name — and this card is what made that reachable, by + moving `positions` onto an axis a tenant admin can write. Reading the name would + have let a tenant mint platform standing and pass the `/admin/*` mount gate. + `platform-admin-gate.ts` drops its positions leg for the same reason. + `session-platform-admin-rung-agreement.test.ts` requires the payload alias, that + gate and `hasPlatformAdminStanding` to agree, driven with such a row present and + a genuine grant as the control. + + **Upgrade.** If you gate on the better-auth role scalar, read `user.role` + instead of looking for its tokens in `user.positions`. Predicates written + against real position names, built-in identity names, or `everyone` need no + change — they start working. Deployments that stored business role names in + `sys_user.role` rather than assigning positions should assign them through + `sys_user_position` (the governed ADR-0090 D12 channel). + + A name in `sys_member.role` is still projected, **with one carve-out**: for a + session carrying NO active organization, membership names are now *added*, from + **every** membership the user holds — the resolver projects them all when no + tenant scopes it, where the old derivation contributed none. Measured on the + real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): + `[]` before, `[org_admin, everyone]` after, pinned by + `session-positions-security-axis.test.ts`. With an active organization the + projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so + membership-derived names there are unchanged. +- 5f7fa1d: feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049) + + + + **BREAKING** key removal on a published session type, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — `api/SessionUser:language` + in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry + `session-user-language-retired` — where `os migrate meta` users will look). + + `SessionUserSchema.language` (`api/auth.zod.ts`) was declared + `z.string().default('en')` and described as "Preferred language", and had no + producer and no consumer anywhere: no session endpoint ever wrote it, no client + ever read it (objectui measured at its pinned sha: zero readers; the only + in-repo mentions were the schema's own unit test). A reader trusting the + published contract got a constant that was not the user's language — while the + user's real preference had just landed as the first-class column + `sys_user.locale` (#13881), which the session type could not see. Three + spellings of one concept on the published surface, none of them right. The + maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 + enforce-or-remove and make `GET /auth/me/localization` the ONE read face for + the signed-in user's language. No replacement field joins the session contract + until a session endpoint really produces one — no dual-spelling window. + + FROM → TO: + + - `SessionUser.language` / `SessionUserParsed.language` → *(removed)*. Read + the signed-in user's language from `GET /auth/me/localization` → `locale`, + which now resolves the user's own `sys_user.locale` when set → the request's + `Accept-Language` → the deployment default (`@objectstack/plugin-hono-server` + in the same release). + + One-line fix: delete the key. A producer still writing it fails `tsc` + (`never` input type) and fails to parse with this prescription; a reader still + keying on it now reads `undefined` instead of a permanent `'en'`, and should + read `locale` off `/auth/me/localization` instead. + + The retirement kit: + + - **`retiredKey()` tombstone** (the schema is a non-strict `z.object`, so a bare + delete would have stripped the key silently — ADR-0104): writing `language` + is a `tsc` error and a parse error carrying the prescription, on + `SessionUserSchema` and through both envelopes that embed it + (`SessionResponse.data.user`, `UserProfileResponse.data`). + - **ADR-0087 registration**: `api/SessionUser:language` under major 18 plus + the D3 semantic entry `session-user-language-retired`. A RESPONSE surface — + the server mints a `SessionUser`, nobody authors or persists one — so there + is no source for a D2 conversion to rewrite (the + `api/AuthFeaturesConfig:passkeys` disposition). + - **generated baselines**: `authorable-surface/api.json` carries the + `[RETIRED]` row; `authorable-defaults/api.json` drops the `= "en"` default; + `spec-changes.json`, the upgrade guide and `content/docs/references/api/auth.mdx` + regenerated. + - **pins** in `api/auth.test.ts`: the prescription on parse, absence (no default + minted) on a clean parse, both envelopes refusing the key, and a + `packages/spec/src`-scoped scan for any reader of `.language` off a + `SessionUser`. + - zero in-tree producers or readers, so no in-repo source changes ride along + beyond the endpoint change shipped with it. +- 87f0ccc: feat(spec): `SharingRuleEvaluationResult` declares `grantsRefused?: number` — the optional seventh key the sharing-rule evaluate route already answers (#14969) + + `minor`, derived: a new key on a published contract interface is additive public + API (semver "backwards-compatible functionality"), and not `major` because the + key is **optional** — every existing `ISharingRuleService` implementer, in-tree + and out, keeps compiling unchanged, and every consumer typed against the six + counts keeps reading them. + + `POST /api/v1/sharing/rules/:idOrName/evaluate` (ledgered `sdk`, + `shares.rules.evaluate`) passes the service's return value through unfiltered, + and `@objectstack/plugin-sharing` has counted refused grants on its own subtype + since #14754 — so the wire carried `grantsRefused` while the declared client + type (`client.shares.rules.evaluate`, typed `Promise`) + could not name it without a cast. The client gains the key through its spec + import with no edit of its own. + + What the key means, and what its absence means: it counts the grants the + engine **refused** during the pass (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on + an organization-less insert into a tenant-scoped `sys_record_share`); the pass + continues past a refusal, so `grantsRefused > 0` is not a failed pass. The key + is **absent — not `0`** — from any implementation that does not count + refusals. A consumer branching on it must read "unset" as "this implementation + does not report refusals", never as "no grant was refused"; only a present `0` + says the latter. Do not `?? 0` it. + + Optional in the spec composes with the plugin-local narrowing: an + implementation that counts refusals may require the key on its own subtype + (`SharingRuleReconcilePassResult extends SharingRuleEvaluationResult`), a legal + covariant narrowing that still satisfies `ISharingRuleService`. +- 69602e5: feat(spec): export `COMPOSE_KEY_DISPOSITIONS` and `STACK_DEFINITION_KEYS` — the artifact envelope's top-level key set and each key's composition rule, derivable from one source instead of hand-copied per consumer (#14877) + + `minor`, additive: two new named exports and two new exported types on the + root entry; nothing renamed, narrowed or removed. Every existing import keeps + compiling and every behaviour of `composeStacks` is unchanged — the table it + reads is the same object, now frozen and public. + + - `COMPOSE_KEY_DISPOSITIONS` — a frozen, read-only record from every top-level + key `ObjectStackDefinitionSchema` declares (`manifest`, `packages`, + `requires`, `objects`, … `onEnable`) to its composition rule: `'concat'` + (an array collection, concatenated in stack order), `'single'` (identical + declarations pass through, differing ones refuse naming the key), + `'manifest'` (picked by the `manifest` option), `'objects'` (the + `objectConflict` strategy) or `'functions'` (merged by handler name). + Literal-typed, so `(typeof COMPOSE_KEY_DISPOSITIONS)[K]` is K's disposition, + not the union. + - `STACK_DEFINITION_KEYS` — the top-level key set, derived from that table by + `Object.keys` (never a second literal), frozen. + - `StackDefinitionKey` and `ComposeDisposition` — the key union and the + disposition union, for a consumer that types its own seam against them. + + Why: the collection half of this key set was already derivable downstream + (`PLURAL_TO_SINGULAR`, `METADATA_ALIASES`), but the non-collection keys — + `manifest`, `requires`, `packages`, and whatever comes next — had to be + hand-copied by every consumer that walks an artifact's top level, and that + copy drifted silently twice: objectstack-ai/cloud#897 (`roles` → `positions` + dropped every hosted `positions[]`) and objectstack-ai/cloud#1888 (`packages[]` + dropped by an artifact merge, recreating downstream the duplicate-ownership + state #14599 had repaired at the door). A seam that derives its key set from + `STACK_DEFINITION_KEYS` — and asks `COMPOSE_KEY_DISPOSITIONS[key] === 'concat'` + whether a key may be concatenated across artifacts — picks up the next key + (#14865's `grantedPermissions`) the day the schema declares it, with no edit of + its own. + + Pinned (`compose-key-dispositions-export.pin.test.ts`): the exported key set + equals `ObjectStackDefinitionSchema`'s declared top-level key set in both + directions, the view is frozen, every value is a declared disposition, every + `'concat'` key is what `composeStacks` concatenates and every `'single'` key is + what it passes through or refuses, and the key list is `Object.keys` of the + table. +- 46803fa: feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711) + + + + **BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`. + + ## The ruling (#15711, recorded 2026-09-05) + + A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**. + + - `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked. + - `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label. + - A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required. + - `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset). + + Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default. + + Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence. +- c2a336c: `@objectstack/spec/system` now names the ADR-0030 notification cut-over, so "has this deployment run it?" has a place to be answered. + + `sys_migration` is the ledger a deployment writes to record that a data migration ran against its own database, and consumers read it instead of the platform version. Its well-known ids were `adr-0104-file-references` and `adr-0104-value-shapes` — the two ADR-0104 scans, both driven by an `os migrate` command that records the row. `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) had none. It is destructive and one-way, operators are handed the call verbatim in `docs/handoff/adr-0030-notification-convergence.md`, and it recorded nothing when it ran: a deployment that performed the cut-over and one that never did are indistinguishable from the ledger. A row can only be keyed by an id, so without one the question had nowhere to be answered even in principle. + + Added: `NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'`, exported from `@objectstack/spec/system`. Purely additive — no existing export, schema or predicate changes, and nothing reads the new id yet. + + Deliberately NOT decided here, and the constant's docblock says so rather than leaving its silence to be read as an answer: what a `sys_migration` row under this id means. The two ADR-0104 ids get their `last_run_at` / `applied_at` / `verified_at` / `blocking` semantics from a command that scans, self-checks and only then records; this migration has no command and no self-check, and reports `migrated` / `already_done` / `not_applicable` / `error` to its caller instead. Which of those columns one of its runs may claim, whether anything may gate on the row, and whether a datastore created after the cut-over belongs in `CREATION_ATTESTED_MIGRATION_IDS`, are contract questions on this surface and are left open. +- 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + + `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. + + - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. + + **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. + + **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. + + An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. +- e9fcd6b: feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) + + + + **BREAKING** — fifteen published `system/` duration keys are renamed and + tombstoned. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescriptions are registered under protocol + major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, + 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared + `kernel/`; this card clears `system/`. Measured with the gate itself: + `src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls + **22 → 7**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `CacheTier.ttl` | `ttlSeconds` | seconds | + | `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | + | `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | + | `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | + | `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | + | `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | + | `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | + | `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | + | `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | + | `StorageConnection.timeout` | `timeoutMs` | milliseconds | + | `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | + | `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | + | `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | + | `Span.duration` | `durationMs` | milliseconds | + | `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | + + **Every value is unchanged** — only key names move, and every default moves with + its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to + 300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, + `RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so + `syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything + under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` + at the authoring site (input type `never`) and fails the parse with the rename + prescription rather than a bare unrecognized-key error. + + ## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them + + `AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. + Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare + name under an `externalVocabulary` marker. + + The asymmetry is the whole point. Every bucket-CORS standard the first value is + forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, + Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation + from* the cited standard rather than a mirror of it. The Fetch response header + the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. + A find-and-replace across both leaves no gate red: the marker exempts the twin + either way. A pin test in `object-storage.test.ts` is the only guard. + + ## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` + + The gate prints `sizeSeconds` for the two `window.size` keys, and that name is + wrong on its face. `size` means a byte or row count everywhere else in this spec + — `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and + `MetricExportConfig.batch.size` on the very same file is a record count — so + `sizeSeconds` would have kept the misleading half of the name and bolted a unit + onto it. `windowSeconds` was rejected for a plainer reason: the parent key is + already `window`, so it would read `window.windowSeconds`. + + `durationSeconds` names what the number is, and the file supplied its own + precedent: `ServiceLevelObjective.period.duration` already called a period + length a duration. After the rename all three read alike. The prescription says + so explicitly, so the next author does not read the departure as a slip and + "correct" it back to the mechanical name. + + ## Dispositions — eight semantic entries, no D2 conversion + + Justified per key rather than defaulted, and this card's answer is uniform: + **none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs + over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, + `disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or + `worker` root — none of these twelve defs is a stack collection member or a + registered metadata kind stored as a `sys_metadata` row, so the conversion chain + has no seam that would see one. They are host configuration (`CacheTier`, + `FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, + `QueueConfig`), call arguments (`CollaborationSessionConfig`) and + runtime-emitted measurements (`Span`). Each therefore carries a **semantic** + entry, which is what ruling B prescribes for a key that is not authorable stack + metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, + nested spellings included. + + ## Keys deliberately left alone + + `FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS + resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. + `CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is + milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must + not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a + record count and `QueueConfig.rateLimit.max` is a task count: neither is a + duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s + burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit + anywhere in their prose, so both are outside the gate's population entirely. + Pin tests assert each of these, so a later sweep cannot read this card as + "every duration-shaped number on these files". +- fb77aa5: feat(spec)!: a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention). Maintainer ruling 2026-09-05 on #14892, option A. + + **What changes.** `ObjectSchema` (and `ObjectExtensionSchema`, judged against + the object it extends) now refuses a field declared `type: 'tree'` whose + `reference` names any object other than the declaring one. The refusal is a + located parse issue at `fields..reference` whose message names both + objects and the three ways out: drop `reference` (it is optional on a `tree`), + name the object itself, or declare a `lookup` if a link to a different object + was meant. `FieldSchema` alone is unchanged — a field does not know which + object declares it, so the judgment lives on the object door. + + **Why.** A hierarchy is parent/child within one object, and that is what every + reader of the type already assumed: the tree renderer's parent-pointer + auto-detection takes the first `tree` field as the object's own parent column, + four prose surfaces said self-reference, and `deleteBehavior` materialises on + `tree` beside `lookup` because a self-referential hierarchy is a relation whose + cascade is exactly the intended semantics. The designer's shared `reference` + input reused one "Target object name" help text for three types, and the one + shipped `tree` example pointed at another object under a hedging label — two + spellings parsed silently, and an example taught a third. The key is now + enforced with one meaning; `reference` stays optional on a `tree` as a + redundant self-annotation, which is also what makes a reference-less `tree` + being classified `relation` (and materialising `deleteBehavior`) coherent. + + **Alongside.** `checkViewCompleteness`'s parent-pointer predicate reads the + same rule: a `tree` field is a detectable parent pointer only when its + `reference` is absent or the object's own name, so a `tree` view bound to an + object whose only `tree` field points elsewhere is reported `view/tree-without- + parent-field` rather than blessed. The designer help text for the shared + `reference` row now says so for `tree`, the showcase `showcase_field_zoo.f_tree` + is a self-reference, and the data-modeling docs say "optional and, if given, + must be this object". + + ```ts + // accepted — a self-reference, or no reference at all + parent: { type: 'tree', reference: 'category' } + parent: { type: 'tree' } + // refused at parse — `fields.parent.reference` on object `category` + parent: { type: 'tree', reference: 'department' } + ``` + + **Not measured.** Out-of-repo cross-object trees are NOT MEASURED: no customer + application was surveyed for a `tree` field pointing at a different object. + In-repo, every other `tree` author is a self-reference or carries no + `reference`; the objectui pin's unit fixtures are outside this schema's reach + and are listed on the card. +- 581d8f8: feat(spec): `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954) + + `TryCatchErrorValue` — the ONE shape the catch region's author, the engine and the run log share for the value a `try_catch` binds to `errorVariable` (default `$error`) — gains an optional `code: string`: the platform-classified error code (ADR-0112) the failing node's own result carried, e.g. `create_record`'s `DUPLICATE_RECORD`. The engine has bound it since `@objectstack/service-automation`'s #14419 change; the schema was a plain `z.object` that did not declare it, so a round-trip through the declared shape silently STRIPPED the key the engine had put there, and the generated reference page documented four keys where the runtime binds five. The `errorVariable` description on `TryCatchConfig` names `code` too, so the authorable surface documents branching on `$error.code`. + + Typed as an open `string`, deliberately not `StandardErrorCode` and not the ledger union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and `NodeExecutor` is third-party-registrable, so a closed type would be false the moment anyone registers an executor that throws its own code. The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP door; this value is bound in-process and never crosses one. + + Additive and optional: every value that parsed before parses byte-identically, and a binding without a classified code still carries no `code` key — absent means "no classified code", never "nothing failed". Semver: a new optional key on a published schema widens the accept set and the exported `TryCatchErrorValue` type without retiring or renaming anything ⇒ `minor`; no ADR-0087 entry is owed because there is nothing an upgrader must migrate. +- f81afe3: feat(spec)!: a typed expression slot fixes its dialect on the envelope arm too, and refuses a blank string (#15028, #15035) + + + + **BREAKING** accept-set narrowing on the twelve authorable keys typed + `CronExpressionInputSchema` (`system/CronSchedule:expression`, + `ai/KnowledgeRefreshPolicy:cron`, `api/ScheduledExport` and + `api/ScheduleExportRequest` `schedule.cronExpression`, + `automation/ScheduleState:cronExpression`, `integration/DataSyncConfig:schedule`, + `system/CacheWarmup:schedule`, `system/BackupConfig:schedule`, + `system/DisasterRecoveryPlan` `testing.schedule`) and + `TemplateExpressionInputSchema` (`ai/PromptTemplate:system`, + `ai/PromptTemplate:user`, `data/Object:titleFormat`). Shipped as `minor` under + the repo's launch-window convention for breaking changes. Measured cost: zero + — of the 46 author values probed across the repo, the examples, the docs, the + skills and the objectui pin, every one is a bare string or a same-dialect + envelope. + + **What changes** (`packages/spec/src/shared/expression.zod.ts`): + + - The envelope arm of each typed schema is `ExpressionSchema` narrowed to that + one dialect literal. A cron-typed slot accepts a bare string or + `{ dialect: 'cron', source }` only; a template-typed slot likewise for + `template`. An envelope naming any other dialect — `cel` or `template` on a + cron slot, `cel` or `cron` on a template slot, or the retired `js` — is + refused with ONE `invalid_union` at the slot whose message is the slot's + dialect-only sentence (`TYPED_EXPRESSION_DIALECT_ONLY[dialect]`, exported). + Before, the arm was the unrestricted `ExpressionSchema`, so a cron slot + parsed a `cel` envelope green and whatever read it received an expression it + could not schedule — a copy-paste artifact of the untyped schema, never a + decision. + - The bare-string arm refuses a blank string — empty or whitespace-only, the + notion of blank `EvaluatedExpressionSchema` already applies (`source.trim()`) + — with ONE `invalid_union` at the slot whose message is the slot's + source-required sentence (`TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]`, + exported). Before, `.min(1)` did not trim, so `' '` normalized to + `{ dialect: 'cron', source: ' ' }` on every typed slot. + - The author type narrows with it: `CronExpressionInput` / + `TemplateExpressionInput` no longer admit a foreign-dialect envelope, and the + published JSON Schema and the generated reference page declare the envelope's + `dialect` as that one literal. `TypedExpressionDialect` names the pair. + + **What does NOT change.** No cron syntax is judged at parse time; `croner` + judges it where a schedule is wired (`CronSchedule.expression`, the one cron + slot with a reader); no grammar is restated in spec. `'not a cron'` still + normalizes to `{ dialect: 'cron', source: 'not a cron' }`, deliberately: the + repo's two cron grammars already disagree on 5 of 32 probed patterns, and a + restatement would be a third. `ExpressionInputSchema` and `ExpressionSchema` + are untouched — the untyped envelope still takes every declared dialect, and an + envelope with neither `source` nor `ast` is refused exactly as before. + + ```ts + // a cron-typed slot, e.g. defineStack({ jobs: [{ schedule: { type: 'cron', expression } }] }) + expression: '0 9 * * 1-5' // accepted, normalized to { dialect: 'cron', source } + expression: { dialect: 'cron', source: '0 9 * * 1-5' } // accepted verbatim + expression: { dialect: 'cel', source: 'now()' } // refused at jobs.0.schedule.expression + expression: ' ' // refused at jobs.0.schedule.expression + expression: 'not a cron' // accepted — syntax is croner's verdict at schedule time + ``` + +### Patch Changes + +- 07f40e5: A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) + + `POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource): + + ```json + {"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}], + "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]} + ``` + + `min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch. + + What changed: + + - **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies. + - **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached. + - **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path. + - **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it. + + `AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string. +- ceb4877: Documentation: the analytics `where` contract and the `element:number` D3 entry now name the hop an array filter is lowered at. + + Text only — no schema, accept-set, runtime or test behaviour changes. `AnalyticsQuerySchema.where` is still `FilterConditionSchema` and still refuses an array, which is the protocol working as `FilterArray`'s docblock (#5158 ruling C) declares it: a `FilterArray` is input-only authoring sugar, lowered to a `FilterCondition` at the single sink `parseFilterAST` (`@objectstack/spec/data`) the moment it arrives, and only the lowered `FilterCondition` travels any further. + + - `AnalyticsQuerySchema.where`'s `.describe()` gains one sentence pointing array authors at that lowering: an authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire, and this field admits only the lowered `FilterCondition`. It lands in the generated `content/docs/references/{api,data}/analytics.mdx` prop tables, which is where an author reads it. + - The `element-number-filter-rule-array` semantic migration entry recorded its runtime prerequisite one hop too late: "authored array → adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`". `lowerAnalyticsWhere` (`service-analytics`) is the in-process door (#5334) for callers reaching `analyticsService.query` directly. The wire's door is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` before any service code runs, so an un-lowered array is refused there. The entry's reason clause now names that route hop and the `parseFilterAST` lowering the adapter owes before the wire (#15828; the adapter-side fix is objectui#7752). + + The sibling entry `element-record-picker-filter-rule-array` was read for the same claim and does not make it — its measured path is `find()` / `convertQueryParams`, not the analytics wire — so it is unchanged. +- ca326b5: `IApprovalService.recall`'s contract prose names every actor who may recall, and scopes each one by status (#14670) + + **Documentation only — no key, no accepted value, no runtime behaviour moves.** The implementation has been correct since #12775; only the contract's description of it was stale. + + The docstring said *"Only the submitter (or a system context) may recall"*, then widened to `returned` requests in a second paragraph. Both halves were wrong, in opposite directions: + + - **The list was not exhaustive.** A #3424 override actor — a platform or tenant admin holding no approver slot — may recall a `pending` request. That is the in-product recovery path for an approval routed to an unstaffed position, and this same file already documented it 387 lines above the sentence denying it: the docblock on `ApprovalRequestRow.viewer.can_override` spells the override's levers as `(approve / reject / reassign / recall it)`. One file, two contradicting sentences about the same verb. + - **The ADR-0044 widening read as though it applied to that whole list.** It does not. The override and system arms are ANDed with `status === 'pending'` where they are computed, so neither reaches a `returned` request; an override actor is refused there exactly as any other non-submitter (#12775, maintainer ruling 2026-09-02). Abandoning a revision window is the submitter's alone. + + The rewrite makes **status** the axis instead of appending a caveat, so the second defect cannot come back on a re-read: each status carries its own admitted set, and the `returned` bullet says outright that the submitter is alone in it. + + `ApprovalRecallInput.actorId` carried the same stale sentence (*"Must be the request's submitter (or a system context)"*) and is corrected with it. Fixing only the method docstring would have left the contradiction alive on the very input type the corrected method takes. + + The two sibling docstrings sharing that phrasing are **correct and unchanged**: `ApprovalSendBackInput.actorId` and `ApprovalResubmitInput.actorId`. `isOverrideActor` is called from exactly five places in `plugin-approvals` — `decideNode`, `reassign`, `recall`, `attachViewers` and `visibleRequestIds` — and neither `sendBack` nor `resubmit` is among them, so no override actor reaches either. + + The published prose already described the corrected rule (`content/docs/automation/approvals.mdx`: an admin "may act on any `pending` request — approve, reject, reassign it to a real approver, or recall it"). This docstring was the one surface that had not kept up. +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- b548e43: `colorField` now documents what it means: a field to DERIVE a colour from, not a field holding one. + + `TimelineConfigSchema`, `CalendarConfigSchema` and `GanttConfigSchema` each declare a `colorField`, and all three `.describe()` strings said only that the field "determines"/"drives" the colour — `'Field to determine item color'`, `'Field whose value determines the event color'`, `'Field that drives the bar color'`. Read literally, that invites pointing the key at a field whose stored value *is* a colour, which is the one case the renderers need the least: the common author intent is `colorField: 'status'`, a select field whose options already carry the colours. + + The renderers resolve it as a derivation ladder (objectui#7243, shared as `createFieldColorResolver` in `@object-ui/core`): + + 1. the option `color` the field declares for the record's stored value; + 2. else the value itself, when it already is a colour literal (hex 3/6/8-digit, `rgb(...)`, `hsl(...)`); + 3. else each renderer's own last rung — the gantt derives a semantic colour token, the calendar hashes onto its theme-aware palette, the timeline draws its default marker. + + The three strings now say that, each naming its own last rung. **Nothing in the accept set moves**: all three keys stay `z.string().optional()`, and a config pointing `colorField` at a plain hex field is still exactly as valid as before — that is rung 2. This is prose on a declared key, so the only regenerated follower is `content/docs/references/ui/view.mdx`. +- 132742f: The Expression Protocol dialect table no longer names `cron-parser` as the `cron` engine. That package is not a dependency of any ObjectStack package; the row shipped to authors through the generated reference page (`content/docs/references/shared/expression.mdx`) and pointed them at the wrong library for field counts, alias vocabulary and second-field semantics. + + The row now says what the code does: no cron syntax is judged at parse time; `croner` evaluates a cron expression only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → `CronJobAdapter`, where an invalid pattern is refused); every other cron-typed slot is parsed and reaches no engine; and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. Documentation only — no schema, accept set or behaviour changes. +- 85a2459: fix(spec): the dashboard `gap` field no longer describes itself to app authors in Tailwind vocabulary + + `ui/dashboard`'s `gap` key told app authors its value in the vocabulary of a CSS + library they never chose and cannot act on. **Two** independent producer strings + carried that wording, and they feed two independent customer-facing surfaces: + + - `dashboardForm`'s `helpText` — `Grid gap (Tailwind units)` — rendered verbatim in + the Studio property panel, which is spec-driven and feeds this form straight into + the generic form renderer. + - `DashboardSchema.gap`'s `.describe()` — `Grid gap in Tailwind spacing units` — + rendered as this field's row in the published reference page + `content/docs/references/ui/dashboard.mdx`. The reference corpus renders + `.describe()`, never `helpText`. + + Both now read **Space between widgets, in steps of 0.25rem (4 = 1rem)**: what the + author decides, plus the magnitude, stated in a CSS unit instead of a framework's + scale. The magnitude had to survive the rewrite rather than be dropped with the + framework name — the number is a spacing step, so `4` means `1rem` and not `4px`, + and an author who lost that would come away knowing less than before. + + The step size is stated as measured rather than inferred: the dashboard renderer + sets the grid gap as an inline style computed from this key, so every accepted + value is linear and one step is exactly `0.25rem`. "Tailwind units" was doubly + wrong — it named an implementation dependency, and it named one the consumer of + this key does not have. + + **No schema change.** `gap` stays `z.number().int().min(0).optional()` and accepts + exactly what it accepted before; nothing is added to or removed from any public + surface. `columns` is deliberately untouched on both of its producer lines — + `12` is an author-visible fact about the grid being laid out, not a framework + detail — and this is one field's two strings, not a sweep for framework words. + + The `en` metadata-forms translation bundle is a mechanical copy of the form source, + so it is regenerated to match. Translated locales are not touched: regeneration + fills gaps only and never overwrites an existing leaf. +- ab50c8f: Record, on `DeleteDataRequestSchema` itself, what it is for and why the DELETE data door carries no `requestSchema` for it. + + The schema is the request contract of `DataProtocol.deleteData()`, consumed statically through the `DeleteDataRequest` type alias and parsed at runtime nowhere — a grep that finds "exported, documented, zero `safeParse` call sites" is reading the wrong surface, and had already filed it once as a gap. Its docblock now says so; records that the absence of a `requestSchema` on `DELETE /api/v1/data/:object/:id` is a pinned decision (#3899 — the catalog entry states it in place of the key, and `plugin-rest-api.schema-refs.test.ts` goes red if one is added, because the route reads no body); and points at the compile-time check (#15866) under which a field added to the schema as required reddens the door at build instead of being silently unsent. + + Documentation only: no shape, `.describe()` text, or export changes. `@objectstack/spec` ships the new text in its published type declarations and in the source file it publishes directly via its `src/**/*.zod.ts` entry. +- 89cf4d6: `check:api-surface` (and every other gate that reads `packages/spec/dist`) no longer refuses a dist that is exactly current because a source file's mtime moved without its bytes changing. + + The freshness rule shared by four gates and the pre-commit hook compares `dist/**/*.d.ts` mtimes against `src/**/*.ts` mtimes. That is the right primitive — it is the artifact those gates consume, and it sees the hand-edited dist and the toolchain change no content digest can — but it cannot tell a real edit from a rewrite that left the bytes alone. A `git merge` re-checks-out an unchanged source file and bumps its mtime; the build that follows correctly does not run, because turbo's cache hashes content, so it is a cache hit that rewrites nothing and leaves every `dist/` mtime where the previous build left it. The gate then refused a correct dist, and prescribed a full rebuild — minutes, under the shared verify lock — of an artifact that needed none. + + The mtime rule keeps its power to convict and gains one way to be answered. `packages/spec`'s build now records a second stamp beside the existing one, `dist/.build-input-hash-dts`, holding the same build-input digest — but written **only** by a build that actually emitted declarations, so `OS_SKIP_DTS=1` leaves it alone. When that digest equals the sources on disk, the declarations demonstrably describe them and the refusal is cleared. The evidence may only ever **acquit**: a missing, unreadable or mismatched stamp leaves the mtime verdict standing, so nothing that passed before can start failing, and the `OS_SKIP_DTS=1`-on-a-built-tree shape that ruled out `dist/.build-input-hash` for this purpose still fails, because that build never refreshes the new file. + + The refusal message was wrong in the same case and is now driven by what was measured: it names a real content change and prints both digests when the stamp disagrees, says plainly that there is nothing to compare against when no stamp exists, and no longer sends every reader after `OS_SKIP_DTS` regardless of cause. It also notes that a repo-wide `pnpm build` may be a cache hit that rewrites nothing, so the remedy names the package build directly. + + The published tarball gains one 65-byte file next to the stamp it already shipped. +- 1a7a7c9: The environment artifact's `checksum` now states its own coverage boundary, and `grantedPermissions` states that it sits outside the digest by design. + + Describe text only — no key, value schema or accept-set change on `EnvironmentArtifactSchema`, and the digest itself is computed and verified by the control plane, not here. + + - **`checksum`** carried the shared `Sha256DigestSchema` describe ("SHA-256 digest (64 hex chars)"), which says what the value *is* and nothing about what it *covers*. It now has its own field-level describe: the SHA-256 digest of the canonical JSON serialization of the `metadata` block (stable key ordering), computed by the control plane when assembling the GET response — and coverage stops there, so no other key on the envelope is under the digest. The shared `Sha256DigestSchema` describe is unchanged, so every other digest field still inherits it. + - **`grantedPermissions`** gains one sentence group at the end of its describe: it sits beside `metadata`, outside the digest, and integrity of the granted consent set rests on the carrier — the artifact is environment-local and control-plane served (ADR-0003 / cloud ADR-0007) — an accepted boundary of this envelope rather than an oversight. Its five existing clauses (the manifest-`id` keying, the `sys_package_installation` source, the enforcer consumer, absent ≠ `{}`) are unchanged. +- 2c753fe: feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168) + + The previous release declared `AutomationContext.recordLoadDenied?: true` and + said so plainly: **declared, not yet populated on the flow face.** The + script/body face of both action doors emitted the signal, but + `dispatchFlowAction` handed `automation.execute` a context without it, so a + `runAs: 'system'` flow that guarded on the documented key was inert — never + `true`, never wrong, and indistinguishable from a flow whose caller could read + the row. + + **This release populates it, on both doors in one stroke** — REST + `POST /api/v1/actions/...` and the MCP `run_action` bridge: + + ```js + // a runAs:'system' flow, guarding before it acts on the subject row + if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ } + ``` + + - **The exact producer shape, unchanged.** The one shared producer + (`loadActionSubjectRecord` → `actionRecordLoadSignal`) already returns + `{ recordLoadDenied?: true }`, and the flow door now spreads it as a + **sibling of `record`** — never a key on the record, and **absent**, never + `false`, when nothing was refused. So a flow reads it exactly as a handler + does, `recordLoadDenied === true`. + - **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the + load OUTCOME (`subject`) instead of a bare `record`, and derives both the + record and the signal from it. A caller can no longer forward the row while + dropping the verdict that says the caller could not read it — the omission is + a compile error rather than a guard silently inert one door over, which is + the defect the handler-face signal was filed for. + - **Purely additive.** Nothing is refused that was not refused before, no + existing key changes value, and the `recordId` stamp is deliberately kept: + `record.id` still arrives exactly as it did, which is why the flag — and not + `record.id` — is the authorization predicate. Whether the automation engine + *acts* on the key (a flow-level refusal, a step condition) is a separate + decision and is deliberately not part of this change. + - **`@objectstack/spec` (docs only).** The contract's "not yet populated on the + flow face" sentence is retired; no type changes. +- d8d2776: The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did. + + `translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden. +- 5eb24f8: The `PluginSchema` describe strings for `staticPath`, `slug` and `default` now name `ui`, the plugin type the enum actually accepts. + + `PluginSchema.type` is `z.enum(['standard', ...CORE_PLUGIN_TYPES])`, and `CORE_PLUGIN_TYPES` spells the frontend member `ui`. The three describe strings beside it still named `ui-plugin` — a value the same schema refuses two lines above. They are not merely stale: they read as instructions ("Required for `type="ui-plugin"`"), so an author or an agent following the field's own documentation writes a value that is then rejected, with the correct spelling nowhere in the sentence that sent them there. + + The strings now read `(Required for type="ui")`, `(Required for type="ui")` and `(Only one "ui" plugin can be default)`. Because these describes compile into the published JSON Schema and into the generated reference page, the correction reaches every consumer that reads field documentation out of the spec rather than out of the source file — the generated `content/docs/references/kernel/plugin.mdx` table now agrees with the `type` row printed directly above it, which previously listed `'ui'` among the accepted members while the three rows underneath told the reader to write `ui-plugin`. + + No accept/reject behaviour moves: `type: 'ui-plugin'` is refused before and after, `type: 'ui'` is accepted before and after, and no key is added, renamed or removed. The closed-set pin tests that name `ui-plugin` as a non-member are deliberately unchanged — they are the reason this correction is provable. +- cc00df2: ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919) + + `PluginSecurityScanner`, `ScanTarget` and `SecurityIssue` are removed from + `@objectstack/core` in the same PR, under ADR-0049 enforce-or-remove (maintainer + ruling 2026-09-05, director summon #14, decision batch #42). This is the ledger + half: a D3 semantic entry + (`src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts`, + concatenated into `MIGRATIONS_BY_MAJOR[18].semantic` by `gen:migration-registry`) + so the retirement reaches `spec-changes.json` and the generated upgrade guide + rather than being invisible to every upgrade channel. + + FROM `new PluginSecurityScanner(kernel.logger)` → TO nothing: delete the import + and every call. There is no replacement export, and a caller that branched on + `result.status === 'passed'` takes that branch unconditionally — it is the only + branch the scanner ever produced, because four of its five scan methods returned + an empty issue list on every input and the fifth read a vulnerability database + whose only writer had zero callers. + + Why an entry is owed at all, and why D3 rather than a D2 conversion: the class + has no spec schema and never had one. It is a runtime TS class, so there is no + authorable key to tombstone with `retiredKey()` and no stored `sys_metadata` row + a conversion could rewrite — a scanner was constructed per call and every result + lived in a per-instance Map discarded with the object, so + `applyConversionsToStoredItem` has no seam that would ever see one. The enforced + channel is tsc at the consumer's own import site; for anyone it does not reach, + this entry and the upgrade guide are the only channel. That is the + `contracts.IDataDriver.findStream` and `actor-user-roles-to-positions` + disposition, applied to a surface one layer further out than either — those are + declared in `packages/spec`, this one only in `packages/core`. + + Measured, and worth recording because the entries README warns of a regeneration + lap that did not materialise here: `check:generated` reports all 15 artifacts up + to date after the entry landed, and running `gen:spec-changes` and + `gen:upgrade-guide` explicitly moved neither file — a major-18 semantic entry is + not yet projected into either. `registry.ts` is the whole generated diff. + + No behaviour in `@objectstack/spec` changes; this adds a ledger row and the + regenerated region that carries it. +- 5ca314a: Document `publicSharing.enabled` as the standing policy it is, and name the switched-off block among `resolveToken`'s `null` causes. + + The TSDoc above `publicSharing.enabled` read "when false, no share links can be issued for this object" — true, but only the mint half. Since the switch became a standing policy held at every redemption, a block that is off also stops every existing link on it from resolving: links minted while it was on, and links minted through the system-context / `permissive` mint bypass alike. Re-enabling the block serves them again; no row moves. The comment now says so, in the shape the sibling `eligibility` predicate's prose already uses. + + `IShareLinkService.resolveToken` enumerated the causes of its undifferentiated `null` — unknown, revoked, expired, audience, password, record gone, ineligible — without the switched-off block, so an implementer reading the list to enumerate refusal causes got an incomplete set. The list now carries it, in the position the gates run; the contract's design notes gain a matching entry beside the eligibility one, and the `isSystem` mint bypass is marked as mint-only. + + Documentation only: no schema, shape or behaviour change, and the `.describe()` string that feeds the generated reference is untouched. Where the corrected text reaches consumers, measured on the built package: every new line in `share-link-service.ts` ships in the published `dist/contracts/index.d.ts` (the interface-member docs and the module design notes both survive the declaration bundle); the `object.zod.ts` property comment reaches no `.d.ts` (the schema's declaration is an inferred type) and ships through the source file `@objectstack/spec` publishes directly (`src/**/*.zod.ts`) and through `dist/data/index.js.map`. +- 0db2947: Reference pages no longer print `@example` and `@category` tag lines as literal text. + + A module docblock is JSDoc, so its header carries block tags, and the reference-docs + renderer emitted a tag written on a prose line verbatim — 18 such lines reached 14 + customer-facing pages, as `@example Basic field mapping` above a code fence and + `@category Security` at the foot of four `system/` pages. `#13796` removed `@module` + from the page and left these two open, because a blanket `^@\w+` line filter would + have taken reader prose off the page and orphaned the fences below it. + + The verdict is per tag, and the axis is the payload rather than the spelling: + + - **`@example CAPTION` is REWRITTEN** into that caption, in bold, above the block it + captions — the shape `@see` already had (`See also: …`). 12 lines across 10 pages. + Bold rather than a heading because heading renumbering has already run by then, so + an emitted heading would carry a level chosen blind of the page, add entries to the + pages' tables of contents, and put a caption in reach of `check:docs-single-h1`. + - **A bare `@example` is DROPPED.** With no payload it is the `@module` case exactly, + and the fence beneath it is visibly an example without a line announcing one. 2 + lines (`studio/plugin`, `studio/object-designer`), both sitting against the + `check:skill-examples` opt-in marker that was already dropped there. + - **`@category VALUE` is DROPPED.** 4 lines, all reading `Security`, on four pages that + already sit under a `system/` section saying as much — and nothing in the repo reads + the tag: no typedoc or api-extractor (neither is used here), no search index, no + gate. Routing it into page frontmatter instead would publish a field with no + consumer. The tag stays in the source, where it is a legitimate JSDoc tag; only the + rendered page drops it. + + No schema behavior changes. The pins assert on the rendered fragment rather than on the + emitted `.mdx`, because `check:docs` compares the artifact against the source and + reproduced all 18 tag lines faithfully. +- aedbaef: `POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) + + **This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened. + + ### What was measured + + Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at. + + ### The mechanism + + better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear. + + The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture. + + ### The fix + + The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically. + + **Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing. + + **Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt. + + `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent. +- f7db8f4: fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) + + `validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. + + ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. + + Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. + + Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. + + `@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one). +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- 99261a7: Documentation: the `_actions` and `globalActions` convention lists in `translation.zod.ts` now name every key the schema accepts. + + Both docblocks are hand-written prose copies of what one factory declares. `actionTranslationSchema(...)` builds both surfaces — the file says so outright, "Shared by object `_actions` and `globalActions`" — so the two lists carried the identical four addresses (`label`, `confirmText`, `successMessage`, `resultDialog.*`) and the identical two omissions. An author reading either list to learn which keys exist saw a strict subset of what the schema has accepted all along. + + Added to both lists, in the order the factory declares them and matching the spelling already landed in `i18n-resolver.ts`'s own header: + + - `description` — the explanatory line under the title in the action's param dialog, resolved at `objects.._actions..description` with a `globalActions..description` fallback. + - `params..{label, helpText, placeholder, options.}` — the per-parameter translations for an action's param dialog. + + Prose only. No schema, factory or resolver changed: the keys were already declared and already accepted, so nothing about what a bundle validates to moves. `packages/spec` publishes `src/**/*.zod.ts`, which is why documentation-only text still ships and still earns a changeset. +- 81b426f: liveness ledger: `translation`'s `_note` states the live/planned boundary instead of a hand-maintained total + + The header claimed "11 of 12 groups live; the twelfth, `datasets`, …". No reading of the + file's own `props` produces that pair. Measured on this commit, `props` holds fourteen + entries — the eleven translation groups `translationDataShape()` declares, plus `locale` + and the item-identity keys `name` / `label` — of which exactly one row, `flows`, is not + `live`, and `datasets` is one of the groups rather than a twelfth. Counting all of `props` + gives thirteen live of fourteen; counting groups only gives ten of eleven. Neither is + eleven of twelve. + + This is the second wrong total the same sentence has carried. It previously read "10 of 11 + groups live; the one dead group (`validationMessages`) …", describing a group removed in + 17.0.0 (#4667) — prose outliving its subject in the header of the very file whose rows warn + about that. So the integers are deleted rather than re-derived, on the #7377 precedent that + moved this ledger family's other hand-maintained counts out of prose and into a generated + artifact: the sentence now names the BOUNDARY ("every group but `flows` is live"), which the + per-prop rows below it carry and `state-counts.md` totals, and it records why a total taken + over `props` is not a total of groups. Both former totals are kept, quoted, as the + sentence's own correction record. + + Published data, prose only: `liveness/` is in this package's `files` array, so these ledgers + ship in the npm tarball. No `status` value moves, no schema changes and no gate verdict + changes — every non-`live` row in the file, at every nesting level, is `flows` or one of its + children. +- 40a44b9: fix(spec): the `undefined` comparand refusal prescribes the null predicate by its ruled spellings (#14426) + + `parseFilterAST`'s comparand-type door refuses an `undefined` comparand at every + position. Its prescription read "Write null for the null predicate, or omit the + key" — position-agnostic advice that, followed at `{ $gt: undefined }`, produced + `{ $gt: null }`, which the 2026-09-01 ruling refuses one door over (and, at an + `$in` / `$nin` / `$between` member, produced the list shapes refused on + 2026-08-31). Two loud refusals to reach one right answer. + + The sentence now names the null predicate by its complete spellings — + `{"$eq": null}` / `{"$ne": null}` — or omit the key, so following it never lands + in a refusal at any position the sentence is emitted at. No accept/refuse + behaviour changes: same envelope (`INVALID_FILTER` / 400), same path, same + accepted-set and NOT-applied sentences. + ## 17.3.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index f4eb307a51..e946fc75c3 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.3.0", + "version": "17.4.0", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 6c3234d589..82e88ef456 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/trigger-api +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 96de46f7b9..ff6bb62d24 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md index 8cf2a4a4d2..ea192f1a37 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,134 @@ # @objectstack/plugin-trigger-record-change +## 17.4.0 + +### Patch Changes + +- 4f85e4d: fix(trigger-record-change)!: the record handed to a record-change flow no longer aliases the write's payload (#14744) + + + + **BREAKING** for a flow whose `script` node mutates a NESTED value of the + triggering record IN PLACE: that mutation no longer affects the write the flow + was triggered by. Shipped as `patch` — this change moves no public surface (no + exported symbol, no accepted key or value), and under the maintainer's + 2026-09-04 rule (decision batch #35, on #15294) a `fix(` that changes no public + surface stays `patch`, with breaking-ness carried by this banner and the + ADR-0087 disposition rather than by the level. Maintainer ruling 2026-09-04 on + #14744 (decision batch #38, verbatim 「同意」), adopting option A. + + **Why.** `buildContext` builds the flow's `record` as a shallow overlay of the + pre-image, the mutation payload and the after-row. The top-level object was + new, so a flow ASSIGNING a top-level key reached nothing — but every nested + value in it was the engine's own object, shared by reference. One of those is + `ctx.input.data`, and on a `multi: true` update ADR-0058 Addendum II D3 hands + every per-row context that same payload object, which is the SET clause of the + single `updateMany`. A registered function doing `record.tags.push(...)` + therefore wrote the SET clause without assigning any key: every dispatch's + contribution landed on EVERY matched row, including values derived from another + row's pre-image, and #14099's key-set refusal could not see it because no key + was assigned. Measured end to end on the memory driver and on + `@objectstack/driver-sql` (#15356). + + **What changes.** Both flow-facing roots — `record` (and the `params` alias of + it) and `previous` — are decoupled from the engine's state before the flow + runs. Arrays, plain objects, `Date`, `RegExp`, `Map` and `Set` are copied; + primitives, functions and other class instances are shared, which is the + documented and pinned boundary. A flow still mutates its roots freely and still + observes its own writes for the rest of the run; those writes simply reach + nothing outside it. `previous` is decoupled in the same stroke because it is the + engine's single pre-image object and the same hook context reaches every other + flow bound to the same write. + + **What does NOT change.** The engine's write shape. ADR-0058 Addendum II D3 + stands untouched: one payload still serves N rows and every per-row context is + still handed that one object. #14099's key-set refusal is untouched and is not + widened — a hook that assigns the same key with per-row values still passes it, + and divergent key sets are still refused whole. Flow metadata with no registered + function reached nothing before this change and reaches nothing after it: + assignment nodes write the run's variable map, and `update_record` issues its own + by-id write. Lookup expansion (`config.expand`) still grafts onto the record the + flow holds. + + **Consumer note.** A flow that relied on an in-place nested mutation to persist + — which on a by-id write did persist, and on a `multi: true` write corrupted + every other matched row — writes the record with the `update_record` node + instead. That node is the supported per-row write and is unaffected by this + change. +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 74ea8b3a37..9b07f7b873 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md index 771882421b..32da50df6d 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/plugin-trigger-schedule +## 17.4.0 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/core@17.4.0 + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index ebfe7200d7..bf966bcd7e 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.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack — auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)", "main": "dist/index.js", diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index d32b82b11f..7c6597ec2e 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,99 @@ # @objectstack/types +## 17.4.0 + +### Minor Changes + +- 3d3f60e: An approval decision that lands while its flow run strands now says so in fields, not only in prose. + + `POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. + + The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: + + - **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. + - **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. + - **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. + + `@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. + +### Patch Changes + +- 088f761: `createHostImporter` now loads the `import` build of an ALIASED dual-published package, instead of silently keeping its `require` build. + + An alias declaration — `{"dependencies": {"foo": "npm:bar@1"}}` — installs a package whose manifest is named `bar` under the key `foo`. On the path where CommonJS resolution SUCCEEDS, the importer re-decides only the CONDITION (it asks the package which entry an `import()` gets, so the caller's ESM chain and this load share one instance). That re-decision recognised the package root by walking up from the resolved entry until it found a manifest named after the DECLARATION KEY — `foo` — while an aliased install's manifest is named `bar`. The walk therefore never matched, the re-decision produced nothing, and the load fell back to whatever the CommonJS resolver had answered: the `require` condition. + + For an aliased dual publish that left the process holding two live copies of one package — the CommonJS build behind the host importer, the `import` build in the caller's own chain — which is exactly the split the condition re-decision exists to remove: a plugin registry, a singleton kernel, a module-level cache, one copy each. + + The expectation now comes from the host's own declaration (`npm:name@range`, aliased `workspace:name@range`), the same reading the ESM-only fallback finder has used since it learned about aliases. Nothing about the check's strictness moves: an alias naming one package still does not license a directory holding another, and a non-aliased declaration is still verified against its key. Declarations that name a LOCATION rather than a package (`link:`, `file:`) carry no name to expect, so they keep today's behaviour unchanged. + + Measured population for the behaviour change: zero aliased declarations exist across this workspace's 875 dependency declarations, and 867 of 867 installed declarations already match their key — no ordinary, non-aliased install reaches this path. +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [cf9bda4] +- Updated dependencies [a7da4de] +- Updated dependencies [5eb24f8] +- Updated dependencies [cc00df2] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index daccf968b9..24f0c8694b 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Shared interfaces describing the ObjectStack Runtime environment", "main": "dist/index.js", diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index a1cc9fe274..88e27bebda 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,209 @@ # @objectstack/verify +## 17.4.0 + +### Patch Changes + +- c550baf: fix(verify): `os verify` no longer reports a green run over a multi-package app it measured nothing about + + Every reader in this package took the artifact's **flattened** top level and + nothing else. A multi-package app whose definitions live under `packages[]` — + the shape ADR-0130 D4's option B emits — therefore reached `deriveCrudCases` + with no objects and no datasources, and reached `rlsProbePermissionSet` and + `declaredPositionNames` with no objects and no positions. Nothing threw. The run + derived zero CRUD round-trip cases, built an empty RLS probe permission set, + minted no persona for any declared position, and printed `✓ verify passed`. + + That is the most expensive place in the platform for a false green: `verify`'s + entire job is to be the thing that notices. A missing collection is at least + missing — zero coverage dressed as a passing run is not. + + The four reads now resolve through `resolveArtifactPackageOrder` + (`@objectstack/core`, ADR-0130 D4+D5), **flattened top level first**: + + - `deriveCrudCases` — the objects it derives cases for, and the datasource-by- + name map behind ADR-0015's double write gate. Both, because objects alone + would leave a write-opted-in federated object judged against an empty + datasource map and reported read-only, i.e. skipped by a verifier that says it + covered it. + - `declaredPositionNames` — one RLS persona per declared position. + - `rlsProbePermissionSet` — the object grants and the owner-scoped narrowing + that are what make an RLS run a probe rather than a report about the object + gate. + + The top-level read still answers first and is returned untouched, so an app on + today's additive artifact gets a bit-identical answer, and a stack that declares + an empty collection (`objects: []` is truthy) still gets an empty one. Only a + top level that does not carry the key at all consults `packages[]`. A malformed + `packages` array now surfaces `resolveArtifactPackageOrder`'s ADR-0112 refusal + instead of reading as "this app declares nothing". +- Updated dependencies [2ed6be6] +- Updated dependencies [dcad825] +- Updated dependencies [6136293] +- Updated dependencies [07f40e5] +- Updated dependencies [6573af9] +- Updated dependencies [54bb2f1] +- Updated dependencies [fd014b1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [954cb0b] +- Updated dependencies [159dbad] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [c1eafe6] +- Updated dependencies [a775510] +- Updated dependencies [60c0f61] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [4e090ec] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [d30ccb9] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [ef3a138] +- Updated dependencies [fa125f3] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [9c270bb] +- Updated dependencies [fa85759] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [25a3d91] +- Updated dependencies [6615a02] +- Updated dependencies [9f39897] +- Updated dependencies [7bf96cf] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [a7da4de] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [6acb37e] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [9fa5775] +- Updated dependencies [d770b3e] +- Updated dependencies [a4816a7] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [e6279dc] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [46803fa] +- Updated dependencies [8a12067] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [a646120] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [2024eca] +- Updated dependencies [6b8c677] +- Updated dependencies [2e35765] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [69602e5] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [f7db8f4] +- Updated dependencies [0cf0867] +- Updated dependencies [5964124] +- Updated dependencies [9408b7f] +- Updated dependencies [1375344] +- Updated dependencies [ec0a6e7] +- Updated dependencies [1157e7b] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [b398ad2] +- Updated dependencies [6c439f2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/service-analytics@17.4.0 + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/service-automation@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/plugin-sharing@17.4.0 + - @objectstack/service-settings@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 3ebe106911..b696b0e112 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.3.0", + "version": "17.4.0", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module",