From 2be86e7f82cf49890ff12cc7d4aa33d9932e6a69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:15:05 +0000 Subject: [PATCH] chore: version packages --- .changeset/action-doubled-redirect-refusal.md | 53 - .changeset/action-execute-ledger-dead.md | 19 - ...action-newtaburl-requires-opensinnewtab.md | 36 - .../action-param-carryover-declaration.md | 35 - .../action-param-excess-keys-compile.md | 22 - .../admin-has-permission-adr0068-answer.md | 5 - .changeset/admin-remove-user-gate-ordering.md | 40 - ...dr0006-d2-client-environments-namespace.md | 138 - ...dvanced-plugin-lifecycle-config-retired.md | 64 - .../aggregate-unresolvable-column-refusal.md | 5 - .changeset/ai-manifest-cloud-only-boundary.md | 45 - ...tics-bridge-engine-aggregate-vocabulary.md | 33 - .changeset/analytics-current-user-token.md | 9 - ...lytics-dataset-query-declared-5xx-relay.md | 60 - .../analytics-dataset-query-user-message.md | 75 - ...tics-measure-filter-crossobject-refusal.md | 9 - .../app-plugin-artifact-forward-conversion.md | 16 - ...pproval-escalation-enabled-default-flip.md | 48 - .../approval-recall-admin-override-arm.md | 34 - .../approval-recall-refusal-localized.md | 41 - ...roval-snapshot-docstring-audit-evidence.md | 40 - .../approver-org-scoped-team-justification.md | 13 - .../artifact-door-registers-capabilities.md | 38 - .../artifact-forward-conversion-door.md | 10 - ...rtifact-registrar-policies-dead-pointer.md | 41 - ...fact-unbound-form-predicate-root-notice.md | 50 - .../audience-posture-invite-only-default.md | 19 - .changeset/auth-audience-settings-surface.md | 33 - .../auth-settings-bind-window-ordering.md | 52 - .../authz-cache-invalidation-substrate.md | 59 - .changeset/automation-toggle-deny-message.md | 36 - .../automation-toggle-manage-metadata.md | 30 - ...autorestart-covers-thrown-health-checks.md | 30 - .changeset/backfill-orgless-platform-rows.md | 19 - ...fill-zero-organization-is-not-ambiguous.md | 32 - ...batch-capability-and-overlay-boot-seeds.md | 27 - ...batch-curated-capability-existence-read.md | 59 - ...h-validateonly-tombstone-version-anchor.md | 37 - .changeset/boolean-aggregand-ruled-answers.md | 5 - .../boolean-aggregands-numeric-min-max.md | 13 - .../break-glass-guard-self-service-target.md | 18 - .../broken-sweep-filter-not-detector.md | 50 - .../broken-sweep-residue-first-filter.md | 35 - .changeset/build-json-advisory-parity.md | 60 - .../build-json-failure-payload-warnings.md | 79 - .../build-json-undeclared-key-parity.md | 46 - .changeset/build-warning-truncation-notice.md | 37 - .../builtin-column-collision-warning.md | 54 - .changeset/builtin-column-delivery-id-type.md | 46 - .../bulk-write-refusal-message-parity.md | 69 - .../button-icon-describe-shared-resolver.md | 9 - .../changesets-repo-name-and-spec-badge.md | 32 - .../cli-auth-command-examples-resolve.md | 34 - .../cli-auth-config-guidance-resolves.md | 41 - .../cli-command-contribution-retired.md | 56 - .changeset/cli-environments-entity-noun.md | 34 - .changeset/cli-i18n-flow-screen-bucket.md | 44 - .../cli-json-failure-payload-conversions.md | 91 - ...data-stats-runtime-row-and-translations.md | 36 - .changeset/cli-readme-drop-os-studio.md | 20 - ...cli-serve-anchor-optional-import-at-app.md | 44 - .../cli-serve-exhausted-port-search-notice.md | 44 - .../cli-serve-host-importer-caller-base.md | 13 - .changeset/cli-serve-port-drift-notice.md | 29 - .changeset/cli-serve-port-input-validation.md | 51 - .changeset/cli-serve-port-text-read-notice.md | 46 - .../cli-truncation-remainder-notices.md | 41 - .../client-auth-set-initial-password.md | 14 - .../client-json-erasure-in-repo-families.md | 72 - .../client-meta-saveitem-query-options.md | 55 - .../client-packages-write-verbs-bare-row.md | 61 - .changeset/client-precise-sdk-return-types.md | 56 - .changeset/client-saveitem-ifmatch-header.md | 42 - .changeset/client-sdk-example-typecheck.md | 35 - .../client-unannotated-return-erasure.md | 52 - ...clone-permission-set-carries-all-facets.md | 41 - ...cluster-false-authz-posture-declaration.md | 43 - ...nent-node-visibility-guidance-post-5505.md | 29 - .changeset/compound-meta-door-mode-draft.md | 52 - .changeset/console-9602dc820450.md | 200 - ...e-description-drop-fabricated-framework.md | 33 - .changeset/console-readme-resolution-order.md | 32 - .../controlled-by-parent-chain-composition.md | 51 - .../create-objectstack-honest-pm-probe.md | 35 - .changeset/cross-schema-fk-qualification.md | 20 - .../crypto-context-producer-vocabularies.md | 45 - .../crypto-posture-deployment-signal.md | 25 - .../data-engine-datasource-def-catchup.md | 21 - .../data-engine-syncobjectschema-declared.md | 6 - ...datasource-def-credentials-ref-retained.md | 56 - ...ce-driver-handle-introspection-contract.md | 38 - .changeset/dead-testing-cjs-not-published.md | 37 - .changeset/declare-live-rest-config-keys.md | 33 - ...declare-search-clone-response-contracts.md | 39 - ...clared-vs-enforced-permission-set-drift.md | 38 - .changeset/declared-workspace-imports-gate.md | 59 - .../delete-reference-check-system-identity.md | 78 - ...livery-not-eligible-gloss-both-surfaces.md | 36 - ...rived-capability-existence-read-batched.md | 43 - ...tructive-409-force-parity-compound-door.md | 18 - ...dialect-getter-knex-canonical-spellings.md | 5 - .../discovery-version-third-producer.md | 33 - .../dispatcher-meta-org-scope-url-spelling.md | 56 - ...freshness-refusal-derives-package-label.md | 19 - .changeset/doc-when-predicate-cel-fence.md | 62 - .../docs-build-suppress-server-sourcemaps.md | 55 - ...ocs-category-index-cards-declared-pages.md | 46 - .changeset/docs-graphql-stale-references.md | 14 - .changeset/docs-ignore-build-step.md | 53 - .../docs-multishape-variant-subtables.md | 65 - .changeset/docs-nested-item-shape-tables.md | 58 - .changeset/docs-root-meta-declared-pages.md | 35 - .../doctor-organizations-hint-pinned.md | 37 - ...t-detect-multi-value-base-type-mismatch.md | 63 - ...multi-value-column-names-remedy-command.md | 34 - ...nbounded-text-column-over-stale-varchar.md | 68 - .../driver-own-key-undefined-normalisation.md | 63 - ...er-sql-aggregate-backend-fault-envelope.md | 55 - .../driver-sql-bounded-string-spec-parity.md | 37 - .../driver-sql-introspect-fk-schema-scope.md | 39 - .changeset/driver-sql-keyed-text-maxlength.md | 15 - .../driver-sql-logger-receiver-detach.md | 67 - ...ql-mysql-fk-introspection-ordinal-order.md | 31 - ...-sql-single-column-unique-introspection.md | 58 - .../driver-sql-stale-text-column-remedy.md | 51 - .../driver-sql-update-stamp-precision.md | 71 - .changeset/element-form-lint-residue.md | 16 - .changeset/element-form-retired.md | 88 - .changeset/eleven-introspect-contract.md | 5 - .changeset/eleven-introspect-engine.md | 5 - .changeset/eleven-introspect-plugin.md | 5 - .changeset/eleven-maps-move-home.md | 21 - .changeset/engine-findone-predicate-guard.md | 40 - .../entry-nameability-generalized-closure.md | 5 - .changeset/envelope-audit-shared-home.md | 6 - ...xternal-validation-checkonboot-enforced.md | 53 - .../field-conditional-required-ledger-dead.md | 32 - .../field-deletebehavior-reference-only.md | 32 - ...r-master-detail-delete-behavior-options.md | 51 - .../file-hydration-tombstone-agreement.md | 10 - .changeset/flat-input-descriptor-mirror.md | 52 - .changeset/flat-input-envelope-precedence.md | 62 - .changeset/flat-input-ownkeys-own-key-set.md | 48 - .changeset/flow-contender-one-renderer.md | 53 - .changeset/flow-expression-function-table.md | 7 - .changeset/flow-input-schema-invalid-code.md | 29 - .../flow-screen-copy-resolver-family.md | 14 - ...m-predicate-root-vocabulary-per-surface.md | 41 - .changeset/form-view-features-root-refused.md | 9 - .../form-view-option-default-narrowed.md | 44 - .changeset/formfield-constraint-shape.md | 9 - .../four-stale-current-user-binding-sites.md | 33 - .changeset/front-door-examples-compile.md | 24 - .../generator-filename-registry-parity.md | 37 - .../global-search-registry-propagate.md | 5 - .changeset/great-poems-repeat.md | 7 - .changeset/hash-shadow-unique-key-utf8mb4.md | 25 - .changeset/heavy-eels-shave.md | 41 - .changeset/hmr-dev-door-environment-gate.md | 51 - .changeset/hook-ctx-title-accessor.md | 26 - .changeset/hook-input-delete-lands.md | 95 - .changeset/hook-input-symbol-key-refusal.md | 60 - ...t-reload-inert-state-strategies-retired.md | 86 - .../hot-reload-watch-placeholder-retired.md | 86 - .../human-user-predicate-fail-closed.md | 48 - .changeset/i18n-flow-bucket-liveness-gate.md | 50 - .../i18n-generated-leaf-source-provenance.md | 55 - .../i18n-provenance-served-in-eight-sets.md | 57 - .../i18n-source-hash-rollout-eight-sets.md | 75 - .../identifier-ceiling-storage-owned.md | 22 - .changeset/identity-keyed-text-bounds.md | 48 - .changeset/import-job-created-by-bound.md | 39 - .changeset/import-mapping-name-declared.md | 14 - ...t-object-name-override-namespace-prefix.md | 21 - ...ngress-resolved-id-wins-over-payload-id.md | 42 - .../init-scaffold-object-file-spelling.md | 43 - ...ontract-withdraw-indexes-widen-defaults.md | 40 - .changeset/invitation-probe-page-ceiling.md | 42 - .changeset/invite-entry-on-members-tab.md | 44 - .changeset/invoked-as-reciprocal-pointers.md | 15 - .../ipubsub-delivery-guarantee-docblock.md | 38 - .../ipubsub-docblock-staleness-retire.md | 33 - .changeset/ja-jp-webhook-label-consistency.md | 5 - .../kernel-context-preview-mode-retired.md | 67 - .changeset/khaki-donuts-refuse.md | 47 - .changeset/knowledge-manifest-turso-origin.md | 36 - .changeset/lazy-pugs-shake.md | 14 - ...-convergence-registration-and-one-store.md | 91 - ...cle-governance-tenant-scan-read-failure.md | 42 - .../lint-liveness-translation-bundle-walk.md | 15 - .changeset/listview-map-liveness-live.md | 9 - .../liveness-action-dispatch-anchors.md | 43 - .changeset/liveness-citation-key-mention.md | 50 - .changeset/liveness-citation-line-bound.md | 26 - ...iveness-lint-null-collection-item-guard.md | 14 - .../liveness-symbol-anchors-action-object.md | 29 - .changeset/liveness-symbol-anchors-batch-2.md | 44 - .changeset/liveness-symbol-anchors-batch-3.md | 66 - .changeset/liveness-symbol-anchors-batch-4.md | 51 - .changeset/llms-txt-freshness-gate.md | 28 - ...alization-failure-memo-backend-leg-only.md | 37 - .changeset/localization-success-read-cache.md | 58 - .changeset/lock-packaged-permission-set.md | 50 - .changeset/lucky-donkeys-vanish.md | 35 - .changeset/lucky-jokes-shave.md | 53 - .changeset/manager-of-org-screen.md | 27 - ...nifest-contributes-dead-members-retired.md | 60 - .../manifest-contributes-routes-retired.md | 49 - .changeset/manifest-kind-globs-retired.md | 39 - .changeset/mapping-lookup-params-removed.md | 65 - .../marked-refusal-classified-at-producer.md | 57 - .../masterdetail-formtype-vocabulary.md | 14 - .changeset/maxlength-adr0087-ledger-entry.md | 7 - .changeset/maxlength-bounded-string-only.md | 9 - ...-stdio-localization-after-settings-bind.md | 36 - .../mcp-stdio-prebind-memoization-window.md | 7 - ...saging-inbox-authenticated-caller-scope.md | 23 - ...ssaging-inbox-read-authenticated-caller.md | 13 - .changeset/meta-delete-item-carriers.md | 47 - .../metadata-core-cjs-entry-point-loads.md | 47 - .../metadata-fs-watcher-atomic-declared.md | 33 - .../metadata-item-name-grammar-enforced.md | 48 - .changeset/metadata-overlay-read-cache.md | 52 - .../metadata-protocol-specifier-pin-11350.md | 5 - .../migrate-multi-value-columns-command.md | 6 - .../migrate-refuses-unloadable-host-config.md | 44 - .changeset/minlength-bounded-string-only.md | 9 - ...mongodb-boolean-aggregand-ruled-answers.md | 48 - .../mongodb-retired-agg-arms-refused.md | 48 - .../mysql-boolean-row-read-presentation.md | 22 - .../mysql-row-byte-budget-diagnostic.md | 5 - .changeset/namespace-retirement-now-cited.md | 5 - .changeset/nine-parents-keep-details.md | 12 - .../non-unique-keyed-text-remainders.md | 49 - .changeset/nosql-index-unique-scope-docs.md | 5 - .changeset/notification-keyed-text-bounds.md | 15 - ...de-template-locale-is-not-per-recipient.md | 45 - ...y-template-locale-is-deployment-default.md | 51 - .../oauth-resource-identifier-sourced-255.md | 66 - .changeset/object-editmode-declared.md | 27 - .../object-grid-data-view-data-converged.md | 42 - .../object-grid-default-sort-retired.md | 48 - .../object-useractions-view-key-guidance.md | 17 - .../objectql-custom-sql-measure-refusal.md | 7 - .changeset/objectql-engine-getschema-typed.md | 6 - .../objectql-per-measure-aggregate-filter.md | 11 - ...ivileged-reads-join-ambient-transaction.md | 13 - .changeset/olive-crabs-clone.md | 43 - .changeset/olive-donkeys-repeat.md | 45 - .../one-id-shaped-platform-admin-judge.md | 20 - ...openapi-info-version-is-the-api-version.md | 28 - ...napi-served-info-version-passes-through.md | 43 - .changeset/operation-message-refusal-keys.md | 36 - .../operation-message-zhcn-fullwidth-comma.md | 28 - .changeset/orange-planets-sniff.md | 11 - .changeset/org-membership-limit-unbounded.md | 39 - .../org-scoped-presentation-capability.md | 41 - ...tion-field-pin-annotation-prose-refresh.md | 21 - .../orphan-hash-shadow-column-cleanup.md | 13 - .changeset/out-of-repo-package-names.md | 48 - .changeset/outbox-ack-claim-credential.md | 11 - .changeset/outbox-ack-status-precondition.md | 53 - .changeset/package-door-declared-code.md | 41 - .changeset/package-door-user-message.md | 57 - .../page-component-responsive-removed.md | 106 - .../page-component-type-vocabulary-gate.md | 13 - .changeset/page-declaration-shape-gate.md | 5 - .changeset/permission-metadata-door-lock.md | 9 - .../permission-restore-purge-retired.md | 96 - .../permission-set-refusal-visibility.md | 44 - .changeset/permission-set-residue-fallback.md | 38 - .changeset/perrow-sandbox-signal.md | 6 - ...pg-date-calendar-day-not-local-midnight.md | 61 - .../pg-introspect-fk-join-correlations.md | 17 - .changeset/plain-pandas-battle.md | 19 - .../planned-liveness-verdict-not-dead.md | 28 - .../platform-admin-capability-export.md | 6 - .changeset/platform-admin-config-anchor.md | 16 - .changeset/platform-owner-wall-bypass.md | 34 - ...plugin-auth-cookiecache-cost-disclosure.md | 52 - .../plugin-auth-logger-receiver-detach.md | 41 - ...plugin-auto-restart-never-reinitialised.md | 94 - .changeset/plugin-keyed-text-bounds.md | 48 - .changeset/plugin-wired-runtime-roster.md | 5 - .../pointer-pair-adoption-five-objects.md | 11 - .changeset/pointer-pair-seed-resolution.md | 9 - .../public-picker-reference-key-prose.md | 32 - .../publish-sys-activity-builtin-vocab.md | 10 - .../published-packages-declare-exports.md | 66 - .changeset/quiet-pugs-shave.md | 13 - .../radio-multiple-refused-at-schema.md | 50 - .../rbac-catalog-seed-refusal-is-loud.md | 44 - .changeset/react-tier-vocab-converge.md | 6 - .../record-activity-types-open-vocabulary.md | 33 - .../record-alert-visiblewhen-migration.md | 44 - .../record-details-headercolor-enum-12126.md | 5 - ...details-section-presentation-keys-11661.md | 5 - ...ecord-details-section-presentation-keys.md | 24 - .changeset/redshift-connect-timeout-bound.md | 5 - .../referential-field-clear-crud-exemption.md | 47 - .../registry-collision-order-symmetric.md | 41 - .changeset/registry-collision-quote-style.md | 49 - ...-object-definitions-docblock-closed-set.md | 51 - ...try-register-object-optional-package-id.md | 53 - ...report-export-column-inference-all-rows.md | 5 - ...reports-save-input-contract-at-the-door.md | 46 - .changeset/rest-api-config-parsed-not-cast.md | 90 - .changeset/rest-discovery-version-producer.md | 53 - .../rest-meta-audit-reset-casts-retired.md | 34 - .../rest-package-id-registry-read-refusal.md | 43 - ...rest-package-list-registry-read-refusal.md | 17 - .changeset/rest-project-resolution-parsed.md | 30 - .../rest-publish-meta-item-cast-retired.md | 40 - .../retire-auth-scim-provider-schema.md | 13 - ...etire-compound-name-metadata-addressing.md | 57 - .../retire-lookup-fk-reference-to-branch.md | 42 - .../retire-plugin-metadata-inert-fields.md | 47 - .changeset/retire-set-user-role-action.md | 14 - .changeset/retire-sys-scim-provider-object.md | 23 - .changeset/retired-defaulted-key-tolerance.md | 9 - .../richtext-code-text-family-emission.md | 11 - .../root-entry-nameability-data-delta.md | 19 - .changeset/root-entry-type-reexports-11350.md | 5 - .changeset/run-dev-unbuilt-workspace-lead.md | 36 - ...ntime-config-client-error-reporting-dsn.md | 55 - ...ma-count-counts-the-definitions-shipped.md | 36 - .changeset/schema-diff-unreachable-kind.md | 15 - .changeset/scim-stable-migration.md | 63 - .changeset/sdk-response-contracts-bound.md | 72 - .../sdui-parser-inert-expression-lockstep.md | 37 - .../sdui-parser-inline-boundary-space.md | 21 - .../sdui-parser-unconsumed-widget-option.md | 48 - .../sdui-parser-union-arm-type-mismatch.md | 36 - .changeset/secret-orphans-command.md | 15 - .../secret-reference-union-asks-the-engine.md | 43 - ...rity-plugin-console-backed-default-sink.md | 23 - ...d-loader-sole-organization-read-failure.md | 30 - .../seed-name-lookup-unscoped-page-budget.md | 48 - .changeset/seed-pass2-keyless-writeback.md | 5 - .../seed-required-deferral-early-signal.md | 41 - ...ed-tenancy-counter-handoff-writes-first.md | 50 - ...ect-option-editability-guidance-bare-id.md | 12 - .changeset/send-template-input-org-retired.md | 44 - .changeset/sendemail-organization-id.md | 15 - .changeset/senderror-carries-declaredcode.md | 40 - .changeset/senderror-carries-usermessage.md | 54 - .../serve-banner-flow-name-shadowing.md | 39 - .../serve-node-env-production-default.md | 62 - .../serve-organizations-pkg-roster-key.md | 35 - .../serve-organizations-pkg-single-sourced.md | 31 - ...erve-organizations-prose-single-sourced.md | 33 - .changeset/serve-publishes-bound-port.md | 29 - .../service-analytics-typecheck-gate.md | 45 - .../settings-getmany-all-or-nothing-doc.md | 36 - .changeset/settings-loadrows-upper-scope.md | 5 - .../setup-packaged-automation-nav-entry.md | 28 - ...hadow-carried-unique-is-not-index-drift.md | 51 - .changeset/shadow-null-safe-key.md | 7 - .changeset/share-door-declared-code.md | 40 - .changeset/share-door-user-message.md | 43 - .changeset/share-user-message-bypass-exits.md | 53 - .../shared-platform-row-org-resolver.md | 15 - .changeset/shared-tenancy-posture-hints.md | 42 - .../sharing-options-logger-guaranteed-warn.md | 24 - .../sharing-sweep-logger-required-warn.md | 31 - .changeset/sharing-write-denial-localized.md | 47 - .../signature-qrcode-bounded-text-family.md | 13 - .changeset/silver-eagles-shout.md | 13 - .changeset/single-source-port-contract.md | 45 - .../skill-catalog-projected-issue-ids.md | 32 - .../skill-examples-extraction-fence-aware.md | 27 - .../skill-refs-exports-fallback-ranking.md | 42 - .changeset/skill-refs-module-docblock.md | 33 - ...d-examples-drop-retired-trigger-phrases.md | 13 - .../smtp-port-contract-single-source.md | 14 - .changeset/spec-audit-meta-item-member.md | 13 - ...spec-broken-sweep-predicate-is-a-filter.md | 31 - .../spec-delete-meta-item-request-members.md | 17 - .../spec-dts-heap-ceiling-fits-container.md | 37 - .../spec-flow-function-broken-sweep-filter.md | 46 - .changeset/spec-publish-command-spelling.md | 33 - .changeset/spec-publish-meta-item-member.md | 14 - .changeset/spec-refusal-message-issue-ids.md | 39 - .../spec-routes-doc-redirect-http-server.md | 40 - .changeset/spec-src-tsdoc-example-surface.md | 22 - .../sql-driver-field-string-comment-fix.md | 43 - .../sql-emission-identity-one-source.md | 55 - .../sqlite-audit-column-canonical-default.md | 28 - .changeset/sqlite-json-injective-codec.md | 109 - ...te-primary-key-not-unique-introspection.md | 9 - .changeset/sqlite-rebuild-applied-honesty.md | 17 - .changeset/stale-fill-i18n-leaf-repair.md | 31 - ...amp-organization-on-notification-writes.md | 41 - .../standalone-project-resolution-declared.md | 41 - .changeset/start-port-forwarding-channel.md | 49 - .../storage-tombstone-download-live-holder.md | 18 - ...egy-context-aggregation-method-narrowed.md | 42 - .changeset/string-family-maxlength-varchar.md | 40 - ...hreshold-binds-from-every-failed-status.md | 49 - .changeset/surface-context-contract-1610.md | 5 - .changeset/sys-activity-declares-scheduled.md | 34 - .../sys-activity-type-open-vocabulary.md | 43 - .changeset/sys-file-organization-stamping.md | 61 - .../sys-file-stranded-orphan-inventory.md | 43 - .changeset/sys-metadata-activation-ledger.md | 59 - ...s-secret-cross-producer-reference-union.md | 52 - .../sys-secret-tri-producer-field-help.md | 42 - .../testing-subpath-exports-esm-only.md | 59 - .changeset/thin-yen-keeps-no-cents.md | 7 - .../tombstone-citation-unwritten-decision.md | 5 - .changeset/translation-refs-flows-leg.md | 35 - .../translation-submit-label-retired.md | 77 - ...rso-json-column-type-asymmetry-declared.md | 63 - .../twelve-dataengine-contract-adoption.md | 5 - .../type-compat-text-signature-qrcode.md | 7 - ...connection-widgets-unknown-keys-refused.md | 57 - ...-mcp-connect-agent-unknown-keys-refused.md | 59 - .changeset/updatemany-write-coercion.md | 41 - .../upload-session-organization-stamp.md | 36 - .changeset/user-actions-three-keys.md | 5 - .../validate-json-failure-payload-warnings.md | 82 - .../validate-json-strict-exit-parity.md | 74 - .../validate-sweep-introspection-memo.md | 6 - .../value-roundtrip-conformance-case-set.md | 47 - ...-differ-expects-what-createcolumn-emits.md | 64 - ...w-conversions-reach-all-three-spellings.md | 13 - .../visiblewhen-describe-bound-roots.md | 15 - .changeset/walled-elevation-verified-email.md | 42 - .changeset/walled-owner-email-elevation.md | 55 - ...lled-owner-no-verification-path-warning.md | 27 - .changeset/walled-owner-operator-verified.md | 49 - .../walled-platform-bucket-diagnostic.md | 57 - .changeset/warm-pumas-repeat.md | 20 - .changeset/watcher-unlink-requires-absence.md | 9 - .../where-id-predicate-conflict-refusal.md | 18 - .changeset/wild-pears-remain.md | 34 - .changeset/wild-poems-repeat.md | 30 - .changeset/wild-pugs-shave.md | 11 - .changeset/write-path-refusal-code-parity.md | 42 - apps/docs/CHANGELOG.md | 105 + apps/docs/package.json | 2 +- content/docs/deployment/self-hosting.mdx | 8 +- content/docs/upgrading.mdx | 2 +- docker/README.md | 6 +- examples/app-crm/CHANGELOG.md | 172 + examples/app-crm/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 224 + examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 217 + examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 181 + examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 35 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 168 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 168 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 168 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 2540 +++++++++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 208 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 889 ++++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 243 + packages/cloud-connection/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 167 + .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 167 + .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 167 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 167 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 304 ++ packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 822 ++++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 93 + packages/create-objectstack/package.json | 2 +- packages/drivers/driver-memory/CHANGELOG.md | 239 + packages/drivers/driver-memory/package.json | 2 +- packages/drivers/driver-mongodb/CHANGELOG.md | 370 ++ packages/drivers/driver-mongodb/package.json | 2 +- packages/drivers/driver-sql/CHANGELOG.md | 1473 ++++++ packages/drivers/driver-sql/package.json | 2 +- .../drivers/driver-sqlite-wasm/CHANGELOG.md | 207 + .../drivers/driver-sqlite-wasm/package.json | 2 +- packages/drivers/driver-turso/CHANGELOG.md | 266 ++ packages/drivers/driver-turso/package.json | 2 +- packages/formula/CHANGELOG.md | 156 + packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 402 ++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 210 + packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 516 +++ packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 51 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 530 +++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 441 ++ packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 1256 +++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 189 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 830 ++++ packages/platform-objects/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 156 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 168 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 168 + .../plugins/knowledge-ragflow/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 482 ++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 452 ++ packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 782 ++++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 276 ++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 199 + packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 239 + .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 53 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-reports/CHANGELOG.md | 179 + packages/plugins/plugin-reports/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 1361 ++++++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 444 ++ packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 323 ++ packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 288 ++ packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 156 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 20 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 1340 ++++++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 1115 +++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 134 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 303 ++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 576 +++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 169 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 161 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 335 ++ .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 372 ++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 173 + packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 178 + packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 167 + .../services/service-knowledge/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 501 ++ .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 178 + .../services/service-package/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 178 + packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 287 ++ .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 388 ++ .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 183 + packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 444 ++ .../services/service-storage/package.json | 2 +- packages/spec/CHANGELOG.md | 4052 +++++++++++++++++ packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 167 + packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 167 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 167 + .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 370 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 369 ++ packages/verify/package.json | 2 +- 596 files changed, 32646 insertions(+), 16180 deletions(-) delete mode 100644 .changeset/action-doubled-redirect-refusal.md delete mode 100644 .changeset/action-execute-ledger-dead.md delete mode 100644 .changeset/action-newtaburl-requires-opensinnewtab.md delete mode 100644 .changeset/action-param-carryover-declaration.md delete mode 100644 .changeset/action-param-excess-keys-compile.md delete mode 100644 .changeset/admin-has-permission-adr0068-answer.md delete mode 100644 .changeset/admin-remove-user-gate-ordering.md delete mode 100644 .changeset/adr0006-d2-client-environments-namespace.md delete mode 100644 .changeset/advanced-plugin-lifecycle-config-retired.md delete mode 100644 .changeset/aggregate-unresolvable-column-refusal.md delete mode 100644 .changeset/ai-manifest-cloud-only-boundary.md delete mode 100644 .changeset/analytics-bridge-engine-aggregate-vocabulary.md delete mode 100644 .changeset/analytics-current-user-token.md delete mode 100644 .changeset/analytics-dataset-query-declared-5xx-relay.md delete mode 100644 .changeset/analytics-dataset-query-user-message.md delete mode 100644 .changeset/analytics-measure-filter-crossobject-refusal.md delete mode 100644 .changeset/app-plugin-artifact-forward-conversion.md delete mode 100644 .changeset/approval-escalation-enabled-default-flip.md delete mode 100644 .changeset/approval-recall-admin-override-arm.md delete mode 100644 .changeset/approval-recall-refusal-localized.md delete mode 100644 .changeset/approval-snapshot-docstring-audit-evidence.md delete mode 100644 .changeset/approver-org-scoped-team-justification.md delete mode 100644 .changeset/artifact-door-registers-capabilities.md delete mode 100644 .changeset/artifact-forward-conversion-door.md delete mode 100644 .changeset/artifact-registrar-policies-dead-pointer.md delete mode 100644 .changeset/artifact-unbound-form-predicate-root-notice.md delete mode 100644 .changeset/audience-posture-invite-only-default.md delete mode 100644 .changeset/auth-audience-settings-surface.md delete mode 100644 .changeset/auth-settings-bind-window-ordering.md delete mode 100644 .changeset/authz-cache-invalidation-substrate.md delete mode 100644 .changeset/automation-toggle-deny-message.md delete mode 100644 .changeset/automation-toggle-manage-metadata.md delete mode 100644 .changeset/autorestart-covers-thrown-health-checks.md delete mode 100644 .changeset/backfill-orgless-platform-rows.md delete mode 100644 .changeset/backfill-zero-organization-is-not-ambiguous.md delete mode 100644 .changeset/batch-capability-and-overlay-boot-seeds.md delete mode 100644 .changeset/batch-curated-capability-existence-read.md delete mode 100644 .changeset/batch-validateonly-tombstone-version-anchor.md delete mode 100644 .changeset/boolean-aggregand-ruled-answers.md delete mode 100644 .changeset/boolean-aggregands-numeric-min-max.md delete mode 100644 .changeset/break-glass-guard-self-service-target.md delete mode 100644 .changeset/broken-sweep-filter-not-detector.md delete mode 100644 .changeset/broken-sweep-residue-first-filter.md delete mode 100644 .changeset/build-json-advisory-parity.md delete mode 100644 .changeset/build-json-failure-payload-warnings.md delete mode 100644 .changeset/build-json-undeclared-key-parity.md delete mode 100644 .changeset/build-warning-truncation-notice.md delete mode 100644 .changeset/builtin-column-collision-warning.md delete mode 100644 .changeset/builtin-column-delivery-id-type.md delete mode 100644 .changeset/bulk-write-refusal-message-parity.md delete mode 100644 .changeset/button-icon-describe-shared-resolver.md delete mode 100644 .changeset/changesets-repo-name-and-spec-badge.md delete mode 100644 .changeset/cli-auth-command-examples-resolve.md delete mode 100644 .changeset/cli-auth-config-guidance-resolves.md delete mode 100644 .changeset/cli-command-contribution-retired.md delete mode 100644 .changeset/cli-environments-entity-noun.md delete mode 100644 .changeset/cli-i18n-flow-screen-bucket.md delete mode 100644 .changeset/cli-json-failure-payload-conversions.md delete mode 100644 .changeset/cli-metadata-stats-runtime-row-and-translations.md delete mode 100644 .changeset/cli-readme-drop-os-studio.md delete mode 100644 .changeset/cli-serve-anchor-optional-import-at-app.md delete mode 100644 .changeset/cli-serve-exhausted-port-search-notice.md delete mode 100644 .changeset/cli-serve-host-importer-caller-base.md delete mode 100644 .changeset/cli-serve-port-drift-notice.md delete mode 100644 .changeset/cli-serve-port-input-validation.md delete mode 100644 .changeset/cli-serve-port-text-read-notice.md delete mode 100644 .changeset/cli-truncation-remainder-notices.md delete mode 100644 .changeset/client-auth-set-initial-password.md delete mode 100644 .changeset/client-json-erasure-in-repo-families.md delete mode 100644 .changeset/client-meta-saveitem-query-options.md delete mode 100644 .changeset/client-packages-write-verbs-bare-row.md delete mode 100644 .changeset/client-precise-sdk-return-types.md delete mode 100644 .changeset/client-saveitem-ifmatch-header.md delete mode 100644 .changeset/client-sdk-example-typecheck.md delete mode 100644 .changeset/client-unannotated-return-erasure.md delete mode 100644 .changeset/clone-permission-set-carries-all-facets.md delete mode 100644 .changeset/cluster-false-authz-posture-declaration.md delete mode 100644 .changeset/component-node-visibility-guidance-post-5505.md delete mode 100644 .changeset/compound-meta-door-mode-draft.md delete mode 100644 .changeset/console-9602dc820450.md delete mode 100644 .changeset/console-description-drop-fabricated-framework.md delete mode 100644 .changeset/console-readme-resolution-order.md delete mode 100644 .changeset/controlled-by-parent-chain-composition.md delete mode 100644 .changeset/create-objectstack-honest-pm-probe.md delete mode 100644 .changeset/cross-schema-fk-qualification.md delete mode 100644 .changeset/crypto-context-producer-vocabularies.md delete mode 100644 .changeset/crypto-posture-deployment-signal.md delete mode 100644 .changeset/data-engine-datasource-def-catchup.md delete mode 100644 .changeset/data-engine-syncobjectschema-declared.md delete mode 100644 .changeset/datasource-def-credentials-ref-retained.md delete mode 100644 .changeset/datasource-driver-handle-introspection-contract.md delete mode 100644 .changeset/dead-testing-cjs-not-published.md delete mode 100644 .changeset/declare-live-rest-config-keys.md delete mode 100644 .changeset/declare-search-clone-response-contracts.md delete mode 100644 .changeset/declared-vs-enforced-permission-set-drift.md delete mode 100644 .changeset/declared-workspace-imports-gate.md delete mode 100644 .changeset/delete-reference-check-system-identity.md delete mode 100644 .changeset/delivery-not-eligible-gloss-both-surfaces.md delete mode 100644 .changeset/derived-capability-existence-read-batched.md delete mode 100644 .changeset/destructive-409-force-parity-compound-door.md delete mode 100644 .changeset/dialect-getter-knex-canonical-spellings.md delete mode 100644 .changeset/discovery-version-third-producer.md delete mode 100644 .changeset/dispatcher-meta-org-scope-url-spelling.md delete mode 100644 .changeset/dist-freshness-refusal-derives-package-label.md delete mode 100644 .changeset/doc-when-predicate-cel-fence.md delete mode 100644 .changeset/docs-build-suppress-server-sourcemaps.md delete mode 100644 .changeset/docs-category-index-cards-declared-pages.md delete mode 100644 .changeset/docs-graphql-stale-references.md delete mode 100644 .changeset/docs-ignore-build-step.md delete mode 100644 .changeset/docs-multishape-variant-subtables.md delete mode 100644 .changeset/docs-nested-item-shape-tables.md delete mode 100644 .changeset/docs-root-meta-declared-pages.md delete mode 100644 .changeset/doctor-organizations-hint-pinned.md delete mode 100644 .changeset/drift-detect-multi-value-base-type-mismatch.md delete mode 100644 .changeset/drift-multi-value-column-names-remedy-command.md delete mode 100644 .changeset/drift-unbounded-text-column-over-stale-varchar.md delete mode 100644 .changeset/driver-own-key-undefined-normalisation.md delete mode 100644 .changeset/driver-sql-aggregate-backend-fault-envelope.md delete mode 100644 .changeset/driver-sql-bounded-string-spec-parity.md delete mode 100644 .changeset/driver-sql-introspect-fk-schema-scope.md delete mode 100644 .changeset/driver-sql-keyed-text-maxlength.md delete mode 100644 .changeset/driver-sql-logger-receiver-detach.md delete mode 100644 .changeset/driver-sql-mysql-fk-introspection-ordinal-order.md delete mode 100644 .changeset/driver-sql-single-column-unique-introspection.md delete mode 100644 .changeset/driver-sql-stale-text-column-remedy.md delete mode 100644 .changeset/driver-sql-update-stamp-precision.md delete mode 100644 .changeset/element-form-lint-residue.md delete mode 100644 .changeset/element-form-retired.md delete mode 100644 .changeset/eleven-introspect-contract.md delete mode 100644 .changeset/eleven-introspect-engine.md delete mode 100644 .changeset/eleven-introspect-plugin.md delete mode 100644 .changeset/eleven-maps-move-home.md delete mode 100644 .changeset/engine-findone-predicate-guard.md delete mode 100644 .changeset/entry-nameability-generalized-closure.md delete mode 100644 .changeset/envelope-audit-shared-home.md delete mode 100644 .changeset/external-validation-checkonboot-enforced.md delete mode 100644 .changeset/field-conditional-required-ledger-dead.md delete mode 100644 .changeset/field-deletebehavior-reference-only.md delete mode 100644 .changeset/field-designer-master-detail-delete-behavior-options.md delete mode 100644 .changeset/file-hydration-tombstone-agreement.md delete mode 100644 .changeset/flat-input-descriptor-mirror.md delete mode 100644 .changeset/flat-input-envelope-precedence.md delete mode 100644 .changeset/flat-input-ownkeys-own-key-set.md delete mode 100644 .changeset/flow-contender-one-renderer.md delete mode 100644 .changeset/flow-expression-function-table.md delete mode 100644 .changeset/flow-input-schema-invalid-code.md delete mode 100644 .changeset/flow-screen-copy-resolver-family.md delete mode 100644 .changeset/form-predicate-root-vocabulary-per-surface.md delete mode 100644 .changeset/form-view-features-root-refused.md delete mode 100644 .changeset/form-view-option-default-narrowed.md delete mode 100644 .changeset/formfield-constraint-shape.md delete mode 100644 .changeset/four-stale-current-user-binding-sites.md delete mode 100644 .changeset/front-door-examples-compile.md delete mode 100644 .changeset/generator-filename-registry-parity.md delete mode 100644 .changeset/global-search-registry-propagate.md delete mode 100644 .changeset/great-poems-repeat.md delete mode 100644 .changeset/hash-shadow-unique-key-utf8mb4.md delete mode 100644 .changeset/heavy-eels-shave.md delete mode 100644 .changeset/hmr-dev-door-environment-gate.md delete mode 100644 .changeset/hook-ctx-title-accessor.md delete mode 100644 .changeset/hook-input-delete-lands.md delete mode 100644 .changeset/hook-input-symbol-key-refusal.md delete mode 100644 .changeset/hot-reload-inert-state-strategies-retired.md delete mode 100644 .changeset/hot-reload-watch-placeholder-retired.md delete mode 100644 .changeset/human-user-predicate-fail-closed.md delete mode 100644 .changeset/i18n-flow-bucket-liveness-gate.md delete mode 100644 .changeset/i18n-generated-leaf-source-provenance.md delete mode 100644 .changeset/i18n-provenance-served-in-eight-sets.md delete mode 100644 .changeset/i18n-source-hash-rollout-eight-sets.md delete mode 100644 .changeset/identifier-ceiling-storage-owned.md delete mode 100644 .changeset/identity-keyed-text-bounds.md delete mode 100644 .changeset/import-job-created-by-bound.md delete mode 100644 .changeset/import-mapping-name-declared.md delete mode 100644 .changeset/import-object-name-override-namespace-prefix.md delete mode 100644 .changeset/ingress-resolved-id-wins-over-payload-id.md delete mode 100644 .changeset/init-scaffold-object-file-spelling.md delete mode 100644 .changeset/introspection-contract-withdraw-indexes-widen-defaults.md delete mode 100644 .changeset/invitation-probe-page-ceiling.md delete mode 100644 .changeset/invite-entry-on-members-tab.md delete mode 100644 .changeset/invoked-as-reciprocal-pointers.md delete mode 100644 .changeset/ipubsub-delivery-guarantee-docblock.md delete mode 100644 .changeset/ipubsub-docblock-staleness-retire.md delete mode 100644 .changeset/ja-jp-webhook-label-consistency.md delete mode 100644 .changeset/kernel-context-preview-mode-retired.md delete mode 100644 .changeset/khaki-donuts-refuse.md delete mode 100644 .changeset/knowledge-manifest-turso-origin.md delete mode 100644 .changeset/lazy-pugs-shake.md delete mode 100644 .changeset/ledger-convergence-registration-and-one-store.md delete mode 100644 .changeset/lifecycle-governance-tenant-scan-read-failure.md delete mode 100644 .changeset/lint-liveness-translation-bundle-walk.md delete mode 100644 .changeset/listview-map-liveness-live.md delete mode 100644 .changeset/liveness-action-dispatch-anchors.md delete mode 100644 .changeset/liveness-citation-key-mention.md delete mode 100644 .changeset/liveness-citation-line-bound.md delete mode 100644 .changeset/liveness-lint-null-collection-item-guard.md delete mode 100644 .changeset/liveness-symbol-anchors-action-object.md delete mode 100644 .changeset/liveness-symbol-anchors-batch-2.md delete mode 100644 .changeset/liveness-symbol-anchors-batch-3.md delete mode 100644 .changeset/liveness-symbol-anchors-batch-4.md delete mode 100644 .changeset/llms-txt-freshness-gate.md delete mode 100644 .changeset/localization-failure-memo-backend-leg-only.md delete mode 100644 .changeset/localization-success-read-cache.md delete mode 100644 .changeset/lock-packaged-permission-set.md delete mode 100644 .changeset/lucky-donkeys-vanish.md delete mode 100644 .changeset/lucky-jokes-shave.md delete mode 100644 .changeset/manager-of-org-screen.md delete mode 100644 .changeset/manifest-contributes-dead-members-retired.md delete mode 100644 .changeset/manifest-contributes-routes-retired.md delete mode 100644 .changeset/manifest-kind-globs-retired.md delete mode 100644 .changeset/mapping-lookup-params-removed.md delete mode 100644 .changeset/marked-refusal-classified-at-producer.md delete mode 100644 .changeset/masterdetail-formtype-vocabulary.md delete mode 100644 .changeset/maxlength-adr0087-ledger-entry.md delete mode 100644 .changeset/maxlength-bounded-string-only.md delete mode 100644 .changeset/mcp-stdio-localization-after-settings-bind.md delete mode 100644 .changeset/mcp-stdio-prebind-memoization-window.md delete mode 100644 .changeset/messaging-inbox-authenticated-caller-scope.md delete mode 100644 .changeset/messaging-inbox-read-authenticated-caller.md delete mode 100644 .changeset/meta-delete-item-carriers.md delete mode 100644 .changeset/metadata-core-cjs-entry-point-loads.md delete mode 100644 .changeset/metadata-fs-watcher-atomic-declared.md delete mode 100644 .changeset/metadata-item-name-grammar-enforced.md delete mode 100644 .changeset/metadata-overlay-read-cache.md delete mode 100644 .changeset/metadata-protocol-specifier-pin-11350.md delete mode 100644 .changeset/migrate-multi-value-columns-command.md delete mode 100644 .changeset/migrate-refuses-unloadable-host-config.md delete mode 100644 .changeset/minlength-bounded-string-only.md delete mode 100644 .changeset/mongodb-boolean-aggregand-ruled-answers.md delete mode 100644 .changeset/mongodb-retired-agg-arms-refused.md delete mode 100644 .changeset/mysql-boolean-row-read-presentation.md delete mode 100644 .changeset/mysql-row-byte-budget-diagnostic.md delete mode 100644 .changeset/namespace-retirement-now-cited.md delete mode 100644 .changeset/nine-parents-keep-details.md delete mode 100644 .changeset/non-unique-keyed-text-remainders.md delete mode 100644 .changeset/nosql-index-unique-scope-docs.md delete mode 100644 .changeset/notification-keyed-text-bounds.md delete mode 100644 .changeset/notify-node-template-locale-is-not-per-recipient.md delete mode 100644 .changeset/notify-template-locale-is-deployment-default.md delete mode 100644 .changeset/oauth-resource-identifier-sourced-255.md delete mode 100644 .changeset/object-editmode-declared.md delete mode 100644 .changeset/object-grid-data-view-data-converged.md delete mode 100644 .changeset/object-grid-default-sort-retired.md delete mode 100644 .changeset/object-useractions-view-key-guidance.md delete mode 100644 .changeset/objectql-custom-sql-measure-refusal.md delete mode 100644 .changeset/objectql-engine-getschema-typed.md delete mode 100644 .changeset/objectql-per-measure-aggregate-filter.md delete mode 100644 .changeset/objectql-privileged-reads-join-ambient-transaction.md delete mode 100644 .changeset/olive-crabs-clone.md delete mode 100644 .changeset/olive-donkeys-repeat.md delete mode 100644 .changeset/one-id-shaped-platform-admin-judge.md delete mode 100644 .changeset/openapi-info-version-is-the-api-version.md delete mode 100644 .changeset/openapi-served-info-version-passes-through.md delete mode 100644 .changeset/operation-message-refusal-keys.md delete mode 100644 .changeset/operation-message-zhcn-fullwidth-comma.md delete mode 100644 .changeset/orange-planets-sniff.md delete mode 100644 .changeset/org-membership-limit-unbounded.md delete mode 100644 .changeset/org-scoped-presentation-capability.md delete mode 100644 .changeset/organization-field-pin-annotation-prose-refresh.md delete mode 100644 .changeset/orphan-hash-shadow-column-cleanup.md delete mode 100644 .changeset/out-of-repo-package-names.md delete mode 100644 .changeset/outbox-ack-claim-credential.md delete mode 100644 .changeset/outbox-ack-status-precondition.md delete mode 100644 .changeset/package-door-declared-code.md delete mode 100644 .changeset/package-door-user-message.md delete mode 100644 .changeset/page-component-responsive-removed.md delete mode 100644 .changeset/page-component-type-vocabulary-gate.md delete mode 100644 .changeset/page-declaration-shape-gate.md delete mode 100644 .changeset/permission-metadata-door-lock.md delete mode 100644 .changeset/permission-restore-purge-retired.md delete mode 100644 .changeset/permission-set-refusal-visibility.md delete mode 100644 .changeset/permission-set-residue-fallback.md delete mode 100644 .changeset/perrow-sandbox-signal.md delete mode 100644 .changeset/pg-date-calendar-day-not-local-midnight.md delete mode 100644 .changeset/pg-introspect-fk-join-correlations.md delete mode 100644 .changeset/plain-pandas-battle.md delete mode 100644 .changeset/planned-liveness-verdict-not-dead.md delete mode 100644 .changeset/platform-admin-capability-export.md delete mode 100644 .changeset/platform-admin-config-anchor.md delete mode 100644 .changeset/platform-owner-wall-bypass.md delete mode 100644 .changeset/plugin-auth-cookiecache-cost-disclosure.md delete mode 100644 .changeset/plugin-auth-logger-receiver-detach.md delete mode 100644 .changeset/plugin-auto-restart-never-reinitialised.md delete mode 100644 .changeset/plugin-keyed-text-bounds.md delete mode 100644 .changeset/plugin-wired-runtime-roster.md delete mode 100644 .changeset/pointer-pair-adoption-five-objects.md delete mode 100644 .changeset/pointer-pair-seed-resolution.md delete mode 100644 .changeset/public-picker-reference-key-prose.md delete mode 100644 .changeset/publish-sys-activity-builtin-vocab.md delete mode 100644 .changeset/published-packages-declare-exports.md delete mode 100644 .changeset/quiet-pugs-shave.md delete mode 100644 .changeset/radio-multiple-refused-at-schema.md delete mode 100644 .changeset/rbac-catalog-seed-refusal-is-loud.md delete mode 100644 .changeset/react-tier-vocab-converge.md delete mode 100644 .changeset/record-activity-types-open-vocabulary.md delete mode 100644 .changeset/record-alert-visiblewhen-migration.md delete mode 100644 .changeset/record-details-headercolor-enum-12126.md delete mode 100644 .changeset/record-details-section-presentation-keys-11661.md delete mode 100644 .changeset/record-details-section-presentation-keys.md delete mode 100644 .changeset/redshift-connect-timeout-bound.md delete mode 100644 .changeset/referential-field-clear-crud-exemption.md delete mode 100644 .changeset/registry-collision-order-symmetric.md delete mode 100644 .changeset/registry-collision-quote-style.md delete mode 100644 .changeset/registry-merge-object-definitions-docblock-closed-set.md delete mode 100644 .changeset/registry-register-object-optional-package-id.md delete mode 100644 .changeset/report-export-column-inference-all-rows.md delete mode 100644 .changeset/reports-save-input-contract-at-the-door.md delete mode 100644 .changeset/rest-api-config-parsed-not-cast.md delete mode 100644 .changeset/rest-discovery-version-producer.md delete mode 100644 .changeset/rest-meta-audit-reset-casts-retired.md delete mode 100644 .changeset/rest-package-id-registry-read-refusal.md delete mode 100644 .changeset/rest-package-list-registry-read-refusal.md delete mode 100644 .changeset/rest-project-resolution-parsed.md delete mode 100644 .changeset/rest-publish-meta-item-cast-retired.md delete mode 100644 .changeset/retire-auth-scim-provider-schema.md delete mode 100644 .changeset/retire-compound-name-metadata-addressing.md delete mode 100644 .changeset/retire-lookup-fk-reference-to-branch.md delete mode 100644 .changeset/retire-plugin-metadata-inert-fields.md delete mode 100644 .changeset/retire-set-user-role-action.md delete mode 100644 .changeset/retire-sys-scim-provider-object.md delete mode 100644 .changeset/retired-defaulted-key-tolerance.md delete mode 100644 .changeset/richtext-code-text-family-emission.md delete mode 100644 .changeset/root-entry-nameability-data-delta.md delete mode 100644 .changeset/root-entry-type-reexports-11350.md delete mode 100644 .changeset/run-dev-unbuilt-workspace-lead.md delete mode 100644 .changeset/runtime-config-client-error-reporting-dsn.md delete mode 100644 .changeset/schema-count-counts-the-definitions-shipped.md delete mode 100644 .changeset/schema-diff-unreachable-kind.md delete mode 100644 .changeset/scim-stable-migration.md delete mode 100644 .changeset/sdk-response-contracts-bound.md delete mode 100644 .changeset/sdui-parser-inert-expression-lockstep.md delete mode 100644 .changeset/sdui-parser-inline-boundary-space.md delete mode 100644 .changeset/sdui-parser-unconsumed-widget-option.md delete mode 100644 .changeset/sdui-parser-union-arm-type-mismatch.md delete mode 100644 .changeset/secret-orphans-command.md delete mode 100644 .changeset/secret-reference-union-asks-the-engine.md delete mode 100644 .changeset/security-plugin-console-backed-default-sink.md delete mode 100644 .changeset/seed-loader-sole-organization-read-failure.md delete mode 100644 .changeset/seed-name-lookup-unscoped-page-budget.md delete mode 100644 .changeset/seed-pass2-keyless-writeback.md delete mode 100644 .changeset/seed-required-deferral-early-signal.md delete mode 100644 .changeset/seed-tenancy-counter-handoff-writes-first.md delete mode 100644 .changeset/select-option-editability-guidance-bare-id.md delete mode 100644 .changeset/send-template-input-org-retired.md delete mode 100644 .changeset/sendemail-organization-id.md delete mode 100644 .changeset/senderror-carries-declaredcode.md delete mode 100644 .changeset/senderror-carries-usermessage.md delete mode 100644 .changeset/serve-banner-flow-name-shadowing.md delete mode 100644 .changeset/serve-node-env-production-default.md delete mode 100644 .changeset/serve-organizations-pkg-roster-key.md delete mode 100644 .changeset/serve-organizations-pkg-single-sourced.md delete mode 100644 .changeset/serve-organizations-prose-single-sourced.md delete mode 100644 .changeset/serve-publishes-bound-port.md delete mode 100644 .changeset/service-analytics-typecheck-gate.md delete mode 100644 .changeset/settings-getmany-all-or-nothing-doc.md delete mode 100644 .changeset/settings-loadrows-upper-scope.md delete mode 100644 .changeset/setup-packaged-automation-nav-entry.md delete mode 100644 .changeset/shadow-carried-unique-is-not-index-drift.md delete mode 100644 .changeset/shadow-null-safe-key.md delete mode 100644 .changeset/share-door-declared-code.md delete mode 100644 .changeset/share-door-user-message.md delete mode 100644 .changeset/share-user-message-bypass-exits.md delete mode 100644 .changeset/shared-platform-row-org-resolver.md delete mode 100644 .changeset/shared-tenancy-posture-hints.md delete mode 100644 .changeset/sharing-options-logger-guaranteed-warn.md delete mode 100644 .changeset/sharing-sweep-logger-required-warn.md delete mode 100644 .changeset/sharing-write-denial-localized.md delete mode 100644 .changeset/signature-qrcode-bounded-text-family.md delete mode 100644 .changeset/silver-eagles-shout.md delete mode 100644 .changeset/single-source-port-contract.md delete mode 100644 .changeset/skill-catalog-projected-issue-ids.md delete mode 100644 .changeset/skill-examples-extraction-fence-aware.md delete mode 100644 .changeset/skill-refs-exports-fallback-ranking.md delete mode 100644 .changeset/skill-refs-module-docblock.md delete mode 100644 .changeset/skill-zod-examples-drop-retired-trigger-phrases.md delete mode 100644 .changeset/smtp-port-contract-single-source.md delete mode 100644 .changeset/spec-audit-meta-item-member.md delete mode 100644 .changeset/spec-broken-sweep-predicate-is-a-filter.md delete mode 100644 .changeset/spec-delete-meta-item-request-members.md delete mode 100644 .changeset/spec-dts-heap-ceiling-fits-container.md delete mode 100644 .changeset/spec-flow-function-broken-sweep-filter.md delete mode 100644 .changeset/spec-publish-command-spelling.md delete mode 100644 .changeset/spec-publish-meta-item-member.md delete mode 100644 .changeset/spec-refusal-message-issue-ids.md delete mode 100644 .changeset/spec-routes-doc-redirect-http-server.md delete mode 100644 .changeset/spec-src-tsdoc-example-surface.md delete mode 100644 .changeset/sql-driver-field-string-comment-fix.md delete mode 100644 .changeset/sql-emission-identity-one-source.md delete mode 100644 .changeset/sqlite-audit-column-canonical-default.md delete mode 100644 .changeset/sqlite-json-injective-codec.md delete mode 100644 .changeset/sqlite-primary-key-not-unique-introspection.md delete mode 100644 .changeset/sqlite-rebuild-applied-honesty.md delete mode 100644 .changeset/stale-fill-i18n-leaf-repair.md delete mode 100644 .changeset/stamp-organization-on-notification-writes.md delete mode 100644 .changeset/standalone-project-resolution-declared.md delete mode 100644 .changeset/start-port-forwarding-channel.md delete mode 100644 .changeset/storage-tombstone-download-live-holder.md delete mode 100644 .changeset/strategy-context-aggregation-method-narrowed.md delete mode 100644 .changeset/string-family-maxlength-varchar.md delete mode 100644 .changeset/success-threshold-binds-from-every-failed-status.md delete mode 100644 .changeset/surface-context-contract-1610.md delete mode 100644 .changeset/sys-activity-declares-scheduled.md delete mode 100644 .changeset/sys-activity-type-open-vocabulary.md delete mode 100644 .changeset/sys-file-organization-stamping.md delete mode 100644 .changeset/sys-file-stranded-orphan-inventory.md delete mode 100644 .changeset/sys-metadata-activation-ledger.md delete mode 100644 .changeset/sys-secret-cross-producer-reference-union.md delete mode 100644 .changeset/sys-secret-tri-producer-field-help.md delete mode 100644 .changeset/testing-subpath-exports-esm-only.md delete mode 100644 .changeset/thin-yen-keeps-no-cents.md delete mode 100644 .changeset/tombstone-citation-unwritten-decision.md delete mode 100644 .changeset/translation-refs-flows-leg.md delete mode 100644 .changeset/translation-submit-label-retired.md delete mode 100644 .changeset/turso-json-column-type-asymmetry-declared.md delete mode 100644 .changeset/twelve-dataengine-contract-adoption.md delete mode 100644 .changeset/type-compat-text-signature-qrcode.md delete mode 100644 .changeset/ui-cloud-connection-widgets-unknown-keys-refused.md delete mode 100644 .changeset/ui-mcp-connect-agent-unknown-keys-refused.md delete mode 100644 .changeset/updatemany-write-coercion.md delete mode 100644 .changeset/upload-session-organization-stamp.md delete mode 100644 .changeset/user-actions-three-keys.md delete mode 100644 .changeset/validate-json-failure-payload-warnings.md delete mode 100644 .changeset/validate-json-strict-exit-parity.md delete mode 100644 .changeset/validate-sweep-introspection-memo.md delete mode 100644 .changeset/value-roundtrip-conformance-case-set.md delete mode 100644 .changeset/varchar-differ-expects-what-createcolumn-emits.md delete mode 100644 .changeset/view-conversions-reach-all-three-spellings.md delete mode 100644 .changeset/visiblewhen-describe-bound-roots.md delete mode 100644 .changeset/walled-elevation-verified-email.md delete mode 100644 .changeset/walled-owner-email-elevation.md delete mode 100644 .changeset/walled-owner-no-verification-path-warning.md delete mode 100644 .changeset/walled-owner-operator-verified.md delete mode 100644 .changeset/walled-platform-bucket-diagnostic.md delete mode 100644 .changeset/warm-pumas-repeat.md delete mode 100644 .changeset/watcher-unlink-requires-absence.md delete mode 100644 .changeset/where-id-predicate-conflict-refusal.md delete mode 100644 .changeset/wild-pears-remain.md delete mode 100644 .changeset/wild-poems-repeat.md delete mode 100644 .changeset/wild-pugs-shave.md delete mode 100644 .changeset/write-path-refusal-code-parity.md diff --git a/.changeset/action-doubled-redirect-refusal.md b/.changeset/action-doubled-redirect-refusal.md deleted file mode 100644 index 15c10005c1..0000000000 --- a/.changeset/action-doubled-redirect-refusal.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": patch ---- - -feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519) - -**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under -the repo's launch-window convention for breaking changes. - -Two independent channels could name a post-success destination for one -`type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`, -validated and visible in metadata) and the handler-returned `{ redirectUrl }` -convention (runtime-only). The spec ruled each surface's default in isolation -and said nothing about an action carrying both — so the renderer had to pick, -and the pick lived only in one renderer's implementation (declared `onSuccess` -wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the -doubled channel; ⛔ no `precedence` contract field. - -The measured static-knowability partition: - -- **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is - runtime-only in general (`target` names an opaque registry entry; - `HookBodySchema` declares no return contract) — but `opensInNewTab: true` is - a schema-visible declaration of the handler-redirect channel (its contract is - "pre-open a tab, then drive it to the handler's returned `redirectUrl`"). - A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true` - is now **rejected at parse time**, with guidance naming both channels and the - remedy. Previously the pair parsed clean and one declaration was silently - dead at render. -- **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a - handler that actually returns `{ redirectUrl }` while the action declares - `onSuccess` — now logs a loud `[action-contract]` warning at both dispatch - surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming - the action, both channels, the interim winner and the remedy. Observe-only: - the wire is untouched and the interim renderer precedence stands until the - author takes the remedy. - -Single-channel declarations are untouched and pinned byte-identically: only -`onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and -`opensInNewTab: false` beside `onSuccess` all parse exactly as before. The -corpus was measured at zero doubled producers (this repo's examples and -platform metadata, objectui metadata, and the cloud SSO handoff producers per -the #11519 measurement), so no shipped metadata is affected. - -**Migration.** An action refused by the new refine must pick its one -destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning -`redirectUrl` from the handler), or keep `opensInNewTab` + the handler -redirect and drop `onSuccess`. Which channel is right is an authoring decision -the metadata cannot make for you, and zero such actions exist in any measured -corpus. - - diff --git a/.changeset/action-execute-ledger-dead.md b/.changeset/action-execute-ledger-dead.md deleted file mode 100644 index 677fd30356..0000000000 --- a/.changeset/action-execute-ledger-dead.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: re-classify `action.execute` `live` → `dead` (#13036) - -The entry claimed a `.transform` that lowers `execute` → `target` and drops the -alias. No such transform exists: `packages/spec/src/ui/action.zod.ts` has exactly -two `.transform` calls and both are `lowerRequiresFeature`. The alias and its -lowering were removed together in protocol 17 (#3855, landed 2026-07-28); the key -has been a `retiredKey` tombstone ever since, and `packages/cli/src/utils/lower-callables.ts` -declines to bind a function-valued `execute` on purpose so the tombstone fires. - -Data-only: no schema, no runtime, no authoring surface changes — authoring -`execute` already failed `tsc` and the parse before this, and still does. The row -STAYS, per the `rls.priority` precedent a `retiredKey()` tombstone keeps the key -in the walked shape, so deleting the row would report UNCLASSIFIED. `liveness/` is -in this package's `files` array, so these ledgers ship in the npm tarball and this -is published data. diff --git a/.changeset/action-newtaburl-requires-opensinnewtab.md b/.changeset/action-newtaburl-requires-opensinnewtab.md deleted file mode 100644 index fb90c2e57b..0000000000 --- a/.changeset/action-newtaburl-requires-opensinnewtab.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): enforce the documented `newTabUrl` / `opensInNewTab` co-constraint on `ActionSchema` (#11842) - -**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under -the repo's launch-window convention for breaking changes. - -`newTabUrl`'s doc has always said "Only valid together with `opensInNewTab`", -and every renderer read point agrees: objectui's pre-opened-tab wrapper reads -the key only behind `action.opensInNewTab && newTabUrl`, and no other path -reads it at all. Nothing on the refine chain enforced the pairing, so an -action declaring `newTabUrl` without `opensInNewTab: true` parsed clean and -the key was silently inert — the ADR-0078 declared-but-unenforced shape, -arriving through a documented co-constraint rather than a missing key. - -`ActionSchema` now **rejects at parse time** an action declaring `newTabUrl` -whose `opensInNewTab` is not `true`, with guidance naming the pre-opened-tab -contract and both remedies (declare the flag if a pre-opened tab is intended; -otherwise delete the inert key — behavior is unchanged either way it was -already behaving, because the lone key was never read). An explicit -`opensInNewTab: false` beside `newTabUrl` is refused too, deliberately: -unlike the #11519 doubled-channel rule, `newTabUrl` has no meaning outside -the pre-opened-tab flow, so a declared-off channel leaves the key exactly as -dead as an undeclared one. - -The legal pairing is untouched and pinned byte-identically: `opensInNewTab: -true` + `newTabUrl`, `opensInNewTab` alone, and `opensInNewTab: false` alone -all parse exactly as before. The corpus was measured at zero lone-`newTabUrl` -producers (this repo's examples and platform metadata, objectui's fixtures -and renderer read points, and the cloud SSO producers, which declare the pair -correctly — re-measured at claim per the triage requirement), so no shipped -metadata is affected. - - diff --git a/.changeset/action-param-carryover-declaration.md b/.changeset/action-param-carryover-declaration.md deleted file mode 100644 index f3a91225ee..0000000000 --- a/.changeset/action-param-carryover-declaration.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': patch ---- - -feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992) - - - -The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE -carry-over contract instead of a rendering convention: a param may state, in -metadata, that its value is carried through the action dialog rather than -collected from the user. - -- `carryOver: true` — seed from the current row (`defaultFromRow: true` is - required alongside, enforced at parse time), render as a NON-EDITABLE - summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer, - which omits the param from the submission entirely — a carry-over param is - always sent. -- Aliases: `readonly` / `disabled` are refused with guidance naming - `carryOver` (a field's `readonly` means write-path strip, which is exactly - the wrong half here). -- Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set` - JSON facet params (`object_permissions`, `field_permissions`, - `system_permissions`, `row_level_security`, `tab_permissions`) declare it, - so the sanctioned clone path stops offering five prefilled raw-JSON - textareas an admin could hand-mangle into a clone that grants MORE than its - base. `description` stays an ordinary editable param. The send-side contract - is unchanged (#11703 pin 6 stays green). - -The objectui renderer leg (honouring the declaration in `ActionParamDialog`) -is the downstream card tracked on #11753. diff --git a/.changeset/action-param-excess-keys-compile.md b/.changeset/action-param-excess-keys-compile.md deleted file mode 100644 index da8ec49f5c..0000000000 --- a/.changeset/action-param-excess-keys-compile.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `ObjectSchema.create()` rejects unknown keys on nested action-param literals at compile time (#12615) - -`create()` infers its generic from the argument, so TypeScript's -excess-property (freshness) checking never fires at any depth; the existing -`NoExcessObjectKeys` map compensated only at the top level. A typo'd key on an -`actions[].params[]` literal (measured: `carryOverX` on -`sys-permission-set.object.ts`) therefore passed `tsc` clean and was caught -only by `ActionParamSchema`'s strict parse at module load. - -The same `Record` map is now mirrored over each element of -each action's `params` array, so the typo becomes a located `tsc` error at the -authoring site (`error TS2322 … 'true' is not assignable to 'never'` pointing -at the unknown key). - -Compile-layer signal only — shipped as `patch` because no working code -changes meaning: the strict parse at module load stays the enforcement of -record, nothing changes in what parses or when, and every literal the new -constraint refuses was already refused (later, at import) by that parse. diff --git a/.changeset/admin-has-permission-adr0068-answer.md b/.changeset/admin-has-permission-adr0068-answer.md deleted file mode 100644 index f943583f01..0000000000 --- a/.changeset/admin-has-permission-adr0068-answer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -`POST /api/v1/auth/admin/has-permission` now answers an ObjectStack platform admin from the ADR-0068 platform-authz predicate. The vendor evaluated this permission query on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a genuine platform admin was answered `success: false` — indistinguishable from a plain member. The route is now shaded by an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (an ungranted or unknown permission still answers `false`), while anonymous callers, plain members, and every request body the vendor refuses to evaluate are delegated to the vendor unchanged, byte for byte. diff --git a/.changeset/admin-remove-user-gate-ordering.md b/.changeset/admin-remove-user-gate-ordering.md deleted file mode 100644 index 0c31ac97f9..0000000000 --- a/.changeset/admin-remove-user-gate-ordering.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(auth): authorize before the break-glass guard on `POST /api/v1/auth/admin/remove-user` (#11477) - -The break-glass last-local-credential guard is registered as a global better-auth -`hooks.before`, which runs ahead of an endpoint's own middleware. On -`/admin/remove-user` — served directly by better-auth's router, whose -`adminMiddleware` establishes only a session, with the role decision landing -later inside the vendor's handler — that ordering let the guard's lookup and its -distinctive refusal be reached by any **authenticated** caller before either -authorization layer had run. Because that refusal is target-dependent, the -refusal itself carried a per-record fact about a user the caller was not -entitled to ask about. - -`/admin/ban-user` already ran the same guard **after** authorization: #9652 -shades that path with an ObjectStack raw mount whose platform-admin gate fires -first. One guard, two routes, opposite orders, and nothing asserting either. - -`/admin/remove-user` now carries the same shading, converging the whole -`/admin/*` family on **authorization before the guard**. The mount reuses the -landed #9652 / #9653 pattern and introduces no new mechanism. - -What changes is **when** the guard decides, never **what** it decides: - -- an anonymous caller still gets `401 UNAUTHENTICATED`; -- an authenticated non-admin now gets `403 PERMISSION_DENIED` for every target, - so the guard is unreachable before authorization and its answer no longer - varies with the named user; -- a platform admin is unaffected in every respect — the mount **delegates** into - better-auth rather than re-implementing removal, so the path-keyed hook still - fires and the guard still refuses the removal of the last local password - login, and admission remains the vendor's own decision (#9969). - -An ordering pin ships with the fix so the sequence is mechanically checkable -rather than re-argued: it asserts that one authenticated non-admin naming two -different targets receives **indistinguishable** responses, and — so the pin -cannot be satisfied by deleting the guard — that an admitted platform admin -still hits the guard's refusal, and still succeeds on an ordinary user. diff --git a/.changeset/adr0006-d2-client-environments-namespace.md b/.changeset/adr0006-d2-client-environments-namespace.md deleted file mode 100644 index 0efcf62b5e..0000000000 --- a/.changeset/adr0006-d2-client-environments-namespace.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -"@objectstack/client": minor -"@objectstack/cli": minor ---- - -feat(client,cli)!: `client.projects.*` becomes `client.environments.*`, the scoped sub-client becomes `ScopedEnvironmentClient`, and the unwrap keys follow the wire (#12866, #12882, ADR-0006 D2) - - - - -**BREAKING** public-API rename on `@objectstack/client`, and a breaking change to -the `--format json` payload of the `os environments` command family. It lands -after the v17.0.0 cut, so the lockstep launch-window convention ships it as -`minor` (`scripts/check-changeset-no-major.mjs`); the version number is not the -migration signal here, this entry is. - -This is the **SDK half** of one coordinated cross-repo rename. The **producer -half** is the cloud control plane, which renames the same field keys on the same -endpoints. Neither half ships alone: shipping the SDK half by itself is -ADR-0006 D3, permanently declined, as is any mapping layer between the two -spellings. - -## Migration - -**No aliases exist.** The old namespace is gone, not deprecated — there is no -`client.projects` getter, no `res.project ?? res.environment` hedge, and none is -coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule -「no aliases」 is the standing one). Every call site moves in one edit. - -### Method namespace - -| before | after | -| --- | --- | -| `client.projects.list(…)` | `client.environments.list(…)` | -| `client.projects.get(id)` | `client.environments.get(id)` | -| `client.projects.create(req)` | `client.environments.create(req)` | -| `client.projects.update(id, patch)` | `client.environments.update(id, patch)` | -| `client.projects.delete(id, opts)` | `client.environments.delete(id, opts)` | -| `client.projects.activate(id)` | `client.environments.activate(id)` | -| `client.projects.rotateCredential(…)` | `client.environments.rotateCredential(…)` | -| `client.projects.updateHostname(…)` | `client.environments.updateHostname(…)` | -| `client.projects.updateVisibility(…)` | `client.environments.updateVisibility(…)` | -| `client.projects.listRevisions(…)` | `client.environments.listRevisions(…)` | -| `client.projects.listBranches(id)` | `client.environments.listBranches(id)` | -| `client.projects.renameBranch(…)` | `client.environments.renameBranch(…)` | -| `client.projects.deleteBranch(…)` | `client.environments.deleteBranch(…)` | -| `client.projects.retryProvisioning(id)` | `client.environments.retryProvisioning(id)` | -| `client.projects.listDrivers()` | `client.environments.listDrivers()` | -| `client.projects.packages.*` | `client.environments.packages.*` | - -The URL paths are unchanged — they were already on the `environments` spelling -(`/api/v1/cloud/environments/…`). Only the method namespace and the response -field keys move. - -### Response keys - -| before | after | where | -| --- | --- | --- | -| `res.projects` | `res.environments` | `list` (the `total` key is unchanged) | -| `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` | - -The joined blocks on `get` (`database`, `credential`, `membership`, -`organization`) keep their names, as do every `packages.*` key, the -`delete`/`listBranches`/`renameBranch`/`deleteBranch` payloads (already -`environmentId`-keyed), and `listRevisions`. - -### Two declarations that were false before this change - -Measured 2026-08-28 against the cloud repo's `main`, and corrected here rather -than carried forward under a new spelling: - -- **`create` never answered a `project` key at all.** `POST /api/v1/cloud/environments` - has always answered `{ environment, warnings, durationMs, hostnameAssignment? }`. - The old `{ project: any; database: any }` declaration was not merely - pre-rename, it was wrong against the running control plane — and - `os environments create` read `res.project.id` through it, so the default - `--activate` silently never activated and the table output printed - `undefined`. Both are fixed by this rename. -- **`create` declares no `database` key.** That route does not send one; the key - was declared NON-optional, so `res.database.driver` typechecked and threw. The - method that really answers a `database` block is `get`, which keeps it. - -The keys `create` does send beside `environment` (`warnings`, `durationMs`, -`hostnameAssignment`) are deliberately still undeclared — adding them is new -published surface and a separate decision. - -### The environment-scoped sub-client (#12882) - -The fourth `project`-spelled surface on the same class, folded in by the same -maintainer ruling. ADR-0006's D1 census named three surfaces and missed this one; -it was an oversight, not a deliberate retention. - -| before | after | -| --- | --- | -| `client.project(id)` | `client.environment(id)` | -| `ScopedProjectClient` (exported class) | `ScopedEnvironmentClient` | - -Same no-alias rule: neither old spelling survives. `client.project(id)` is not a -deprecated method, it is gone, and the exported class is gone under its old name -— a `import { ScopedProjectClient }` fails at the import line, which is the -loudest and most precise channel this change has. - -Nothing about the behaviour moves: the scoped client still prefixes -`/api/v1/environments/:environmentId/...`, still exposes the same `data` / `meta` -/ `batch` / `packages` shape, and the thrown guard message becomes -`[ObjectStack] environment(id): environmentId is required`. - -**Deliberately NOT renamed, because each is a different surface needing its own -decision:** `setProjectId` / `getProjectId` on the client — `getProjectId` is a -cross-package protocol contract that `packages/runtime` and -`packages/metadata-protocol` both speak, so it is a coordinated rename, not a -local one — and the REST API config keys `enableProjectScoping` / -`projectResolution`, which are live keys read by `packages/cli/src/commands/serve.ts`. -The docblocks that name them are worded so they stay true. - -Note for whoever compiles the release notes: four other pending changesets in -this release describe methods on `ScopedProjectClient` under its old name -(`client-unannotated-return-erasure`, `client-saveitem-ifmatch-header`, -`client-meta-saveitem-query-options`, `client-precise-sdk-return-types`). They -were accurate when written and are deliberately left alone; this entry is the one -that renames the class. - -### JSDoc - -The `create` docblock claimed the server delegates to -`ProjectProvisioningService.provisionProject`. That spelling has zero hits in the -control plane (measured 2026-08-28). Both this SDK's docblock and -`os environments create`'s now name the **endpoint** instead, which is the one -identifier an in-repo reader can verify — the class lives in a repo this one -never compiles against, so no gate here could ever have caught the rot. - -## CLI - -`os environments list | show | create | switch | bind` follow the same rename. -No flag, argument, exit code or command id changes. `--format json` / `--format yaml` -payloads are `formatOutput(res, …)` straight from the control-plane response, so -their top-level keys change with the wire: a script reading `.projects` or -`.project` from those payloads reads `.environments` / `.environment` instead. diff --git a/.changeset/advanced-plugin-lifecycle-config-retired.md b/.changeset/advanced-plugin-lifecycle-config-retired.md deleted file mode 100644 index a58b7e0388..0000000000 --- a/.changeset/advanced-plugin-lifecycle-config-retired.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the authorable `AdvancedPluginLifecycleConfig` surface; the lifecycle classes stay as a host-driven library (#11825, ADR-0049) - - - -**BREAKING** export removal, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry -`advanced-plugin-lifecycle-config-retired` — where `os migrate meta` users -will look). - -`AdvancedPluginLifecycleConfigSchema` aggregated six lifecycle config groups — -`health`, `hotReload`, `degradation`, `updates`, `resources`, -`observability` — and NO group had a runtime reader, re-measured per group at -the retirement's base commit with positive controls: the kernel never -constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit -tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY -to the classes, never through this container); the `degradation` / `updates` -/ `resources` / `observability` keys have no implementation body at all; and -no manifest, stack collection or metadata-type binding ever embedded the -container, so no authored document could carry it. An author declaring -`health: {...}` or `rollback: { automatic: true }` got a clean parse and -NOTHING — the #3950 shape at container scale, sharpened by production-safety -vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) -an AI author reads as proof the capability exists. - -FROM → TO: - -- `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / - `AdvancedPluginLifecycleConfigParsed` → *(removed — no replacement - container)*. The supported lifecycle surface is the host-driven library in - `@objectstack/core`: construct `PluginHealthMonitor` and pass a - `PluginHealthCheck`, construct `HotReloadManager` and pass a - `HotReloadConfig` (the `content/docs/protocol/kernel/lifecycle.mdx` - examples, #11811, are the supported usage). -- `GracefulDegradationSchema` / `GracefulDegradation` / - `GracefulDegradationParsed` and `PluginUpdateStrategySchema` / - `PluginUpdateStrategy` / `PluginUpdateStrategyParsed` → *(removed)* — value - schemas whose only consumer was the retired container; no implementation - body exists for any of their keys. They return only via the ENFORCE route - of ADR-0049 through a new ADR — the executor first, the vocabulary second. - -One-line fix: delete the config object (it configured nothing); if you drive -the library classes yourself, keep passing `PluginHealthCheck` / -`HotReloadConfig` directly — those vocabularies survive unchanged. - -The retirement kit: - -- whole-def deletion (route 3 — no carrier key, no authored document, so no - tombstone and no D2 conversion; the #8715 `identity/ApiKey` shape): - `kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`, - `kernel/PluginUpdateStrategy` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 - semantic entry `advanced-plugin-lifecycle-config-retired` -- pin test (`kernel/plugin-lifecycle-advanced-retirement.test.ts`): zero - holders for all 9 retired names on every public entry, survivors pinned - (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, - `HotReloadConfig` + `DistributedStateConfig`, `PluginStateSnapshot` — the - kept host-driven library vocabularies) -- zero authored occurrences in objectstack or objectui (measured at - dispatch, re-verified per group), so no in-repo source changes ride along; - `@objectstack/core` classes and tests are untouched and stay green diff --git a/.changeset/aggregate-unresolvable-column-refusal.md b/.changeset/aggregate-unresolvable-column-refusal.md deleted file mode 100644 index 34c6754be1..0000000000 --- a/.changeset/aggregate-unresolvable-column-refusal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`aggregate()` now answers an unresolvable column with the same refusal class as `find()` and `count()` instead of the generic `DATABASE_ERROR`/500 terminal — the #8790 refusal reaching the third read door (#11541). The dialect-named column is attributed to the clause the caller's own query names it in: a `groupBy` field or an aggregation `field` refuses with `INVALID_FIELD`/400 naming the column and the clause (the same code the protocol ingress gives this condition, #4254); a column named by neither clause is the WHERE, which answers #8790's `INVALID_FILTER`/400 refusal verbatim; a dialect wording that yields no column name keeps the #11455 terminal envelope unchanged, because no attribution is supportable there (#8931). Drivers extending `SqlDriver` (`driver-turso`'s embedded face, `driver-sqlite-wasm`) inherit the same answers. diff --git a/.changeset/ai-manifest-cloud-only-boundary.md b/.changeset/ai-manifest-cloud-only-boundary.md deleted file mode 100644 index 567bc78682..0000000000 --- a/.changeset/ai-manifest-cloud-only-boundary.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Tell the operator in Settings → AI that `@objectstack/service-ai` has no -open-edition version to install, instead of three bare "Mount it" lines - -Configure any real LLM provider in **Settings → AI** and press *Test -connection*, and the built-in fallback handler answered — on all three -real-provider branches — "Mount `@objectstack/service-ai` to exercise live -calls." This platform's own capability roster says that cannot be done: -`PLATFORM_CAPABILITY_PROVIDERS.ai` in `@objectstack/spec/kernel` declares -`edition: 'cloud'`, which `CapabilityEdition` defines as "realized only by a -cloud runtime tier; there is **no installable version in the open edition**" — -the un-followable "add it to your dependencies" that framework#3366 exists to -make legible. The package is in no directory of this repo (0 path hits for -`/service-ai/` on `main`; `/service-settings/` returns 64 and -`/embedder-openai/` 8 under the identical command, so the zero is real). - -The instruction is **kept** — an operator may well have a cloud tier — and -gains the boundary it was missing, so one who does not can see the path is -closed to them. - -Worse than a 404, the install succeeds. Measured 2026-08-23 against the public -npm registry (unauthenticated, `@objectstack/spec` + `@objectstack/cli` as -positive controls, `@objectstack/service-ai-studio` — the sibling -`edition: 'cloud'` entry — as a negative control returning 404): -`@objectstack/service-ai` returns **200** with 57 versions, and the highest is -**10.3.0** (2026-06-23) — entirely below the 11.3.0 cut the roster note names, -i.e. the pre-cloud tail left behind on the registry. A determined operator -following the old sentence installs a seven-major-old AI runtime that -exact-pins `@objectstack/spec@10.3.0` against this repo's 17.2.0, resolving a -second spec beside this one. The new message says so, so nobody discovers it -from a dependency error. - -The boundary sentence is **read from the roster**, not hand-written a fourth -time: `note` is documented as "surfaced verbatim inside the preflight / boot -error so the message carries its own context", and `packages/cli`'s capability -preflight already interpolates it the same way. The three provider prefixes -stay distinct — only the shared trailing sentence converges. - -No behaviour change: `ok` and `severity` are untouched on every branch, and the -embedder hint at the fourth site is deliberately left alone (its package -**is** built here, so that instruction is followable as written) and pinned by -a contrast test. diff --git a/.changeset/analytics-bridge-engine-aggregate-vocabulary.md b/.changeset/analytics-bridge-engine-aggregate-vocabulary.md deleted file mode 100644 index 42b1669b3b..0000000000 --- a/.changeset/analytics-bridge-engine-aggregate-vocabulary.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -refactor(service-analytics): derive the analytics auto-bridge's engine view from the declared contracts (#11833) - -`plugin.ts` named the data engine through a consumer-local structural -`DataEngineLike` — the second of the two sites #11833 records, after the -datasource half that landed as PR #12011. It is now derived from the declared -contracts: `IDataEngine.aggregate` / `execute?` / -`resolveEffectiveDatasource?` / `getDriverForObject?` and -`IObjectQLEngine.getObject`. Optionality is preserved exactly — `aggregate` -required, everything else `Partial<>` — because these probes are the plugin's -graceful-degradation seam. - -**Why this is `patch` and not a type-only no-op.** Four of the five members -substitute with no behaviour change. The fifth does not: the deleted structural -type declared `aggregations[].function` as `string`, while the contract -declares the six-value `AggregationFunction`. The bridge therefore forwarded -whatever method string reached it. That forward is now parsed with the spec's -own enum, so a method the engine contract does not declare is refused at the -bridge — loudly, naming the aggregation and the legal vocabulary — instead of -reaching the engine, where `driver-sql` blamed a `function` key the author -never wrote and the in-memory evaluator answered `null` for every bucket under -the author's own measure name. - -No authored analytics can trigger the new refusal: the one reachable producer -of a non-aggregate method — a custom-SQL measure (`AggregationMetricType` -`number` / `string` / `boolean`) — is already refused earlier, caller-facing, -by `ObjectQLStrategy.resolveMeasureAggregation` (#12209). What is left is host -drift (a cube object registered without meeting `CubeSchema`), which is why -the new refusal is a bare `Error` in the undeclared-500 tier rather than an -ADR-0112 400 that would blame the caller for something they did not write. diff --git a/.changeset/analytics-current-user-token.md b/.changeset/analytics-current-user-token.md deleted file mode 100644 index 4d1938f7bb..0000000000 --- a/.changeset/analytics-current-user-token.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -Resolve `{current_user_id}` (and every other filter placeholder) on the direct analytics query path, at parity with the list path and the dashboard dataset path. - -What changes for an app author: a widget or report whose filter says `owner: '{current_user_id}'` used to render `0` for every viewer whenever the query reached the SQL strategy — the literal text was bound into the `WHERE` and matched no row, silently. Now the same filter expression means the same thing on every surface: `AnalyticsService.query` and `generateSql` expand `where`, `timeDimensions[].dateRange`, and a registered dataset's own filter / measure filters against the requesting user before any strategy compiles, so each viewer gets their own rows. A placeholder that cannot be resolved — an unknown spelling, or `{current_user_id}` on an unauthenticated request — now refuses loudly with `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` (HTTP 400) instead of charting a plausible zero. - -This also closes a gap on the dashboard dataset door: the dataset-scope channel used to hand strategies the registry's unresolved filter copy, which was ANDed in beside the resolved one (`owner = $viewer AND owner = '{current_user_id}'`) and selected nothing. diff --git a/.changeset/analytics-dataset-query-declared-5xx-relay.md b/.changeset/analytics-dataset-query-declared-5xx-relay.md deleted file mode 100644 index 33ca8343c1..0000000000 --- a/.changeset/analytics-dataset-query-declared-5xx-relay.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `POST /analytics/dataset/query` relays a producer-declared 5xx instead of collapsing it to `500 ANALYTICS_QUERY_FAILED` (#11718) - -**Response-contract change on a shipped public route.** The door is -`POST /api/v1/analytics/dataset/query` (and its environment-scoped twin). An -error whose producer declared a 5xx `status` now reaches the client with **that -status and that ADR-0112 `code`**, where the route previously answered a -hand-built `500` `ANALYTICS_QUERY_FAILED` for every one of them. - -Measured door-to-door before the fix, one error object, both handlers driven in -process: - -| face | answer | -|---|---| -| `POST /api/v1/data/:object` | `503` — `{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}` | -| `POST /api/v1/analytics/dataset/query` | `500` — `{"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` | - -`/data` is the reference and does not move. Its relay is #5582's ruling — `502` -and `503` are `isExpectedDataStatus` lifecycle outcomes that proxies and retry -policies read differently from a `500`, so collapsing them destroys the -declaration — and that ruling never reached this route because the analytics -catch built its 5xx envelope by hand. The **sibling** analytics face -`/analytics/query` already relayed both halves through -`dispatcher-plugin.errorResponseBase`, so this door was the only one of three -overwriting a producer's declaration. - -The repair imports `/data`'s own arm rather than restating it: the branch is -lifted into `declaredServerFaultAnswer` in `error-response.ts` and read by both -doors, the same way the 4xx arm already imports `classifiedRefusalAnswer`. A -third local opinion at this boundary is how the two faces came to disagree. - -**Not a re-opening of #5352/#5367/#5811 — the prose is still withheld.** A -declared server fault's message is still replaced by the generic sentence, from -the same shared arm, and the full original text still reaches the operator: the -`logError` line runs *before* the relay branch and is unconditional, so a -producer cannot buy its way past the operator's log with a declared status. What -moves is the classification the producer declared and this route was -overwriting. - -**What callers see change:** - -- A declared `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` → `503` - `SERVICE_UNAVAILABLE` (was `500` `ANALYTICS_QUERY_FAILED`). -- An unregistered declared code demotes exactly as `/data` demotes it — `503` - `{"code":"SERVICE_UNAVAILABLE","declaredCode":"WAREHOUSE_UNAVAILABLE"}` (#9232). -- `read-scope-sql`'s ten fail-closed RLS refusals answer `500` - `READ_SCOPE_COMPILE_FAILED` instead of `500` `ANALYTICS_QUERY_FAILED`. Their - 2026-08-06 ruling is untouched in substance — still a SERVER fault, still - `500`, still with the RLS policy content withheld from the body and intact in - the log — and the code they now carry is the one they declare and the one the - sibling `/analytics/query` face has always shipped to clients. - -**Unchanged:** an *undeclared* fault. No declared status means nothing to relay, -so it keeps `500` `ANALYTICS_QUERY_FAILED` and #5667's tiering, which leaves a -self-authored fault readable. A declared **4xx** is untouched — that band is -arms ① and ①b, and the half-envelope rule (a 4xx status with no code invents no -code) still stands. diff --git a/.changeset/analytics-dataset-query-user-message.md b/.changeset/analytics-dataset-query-user-message.md deleted file mode 100644 index 6f09f28926..0000000000 --- a/.changeset/analytics-dataset-query-user-message.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `/analytics/dataset/query` carries a producer-marked `userMessage` on its three hand-built terminals (#12710) - -`POST /api/v1/analytics/dataset/query` (and its environment-scoped twin) builds -its error envelopes by hand and shares no exit with the `/data` door, so #9934's -producer-marked `userMessage` — a channel that door applies once at its exit, -branch-agnostically, through `withDeclaredUserMessage` — was applied at none of -them. A producer's caller-facing sentence reached the client on -`POST /data/:object` and vanished here for the identical throw. - -**Scope is by ARM.** Four terminals live in that route's catch; three dropped -the mark and one did not: - -| arm | envelope | before | -| :-- | :--- | :--- | -| ① declared 4xx ADR-0112 passthrough | hand-built `{ code, message }` | ⛔ no mark | -| ①b `classifiedRefusalAnswer` re-dress | `{ ...refusalFields, message }` | ✅ carried it | -| ③a declared 5xx relay | `declaredServerFaultAnswer`'s body, sent verbatim | ⛔ no mark | -| ③b generic `500 ANALYTICS_QUERY_FAILED` | hand-built `{ code, error }` | ⛔ no mark | - -①b already carried it because its body comes from `resolveErrorResponse`, whose -arms ride the mark already. The other three hold no classification to ride on. - -Measured on `4af6c4419` before the repair, one marked producer per arm, driven -through the real route against the flat `/data` door for the identical throw: - -```text -throw { code: 'INVALID_FILTER', status: 400, userMessage: 'Check the filter…' } - ① analytics : 400 {"code":"INVALID_FILTER","message":"…"} — no mark - /data door : 400 {"error":"…","code":"INVALID_FILTER", - "userMessage":"Check the filter…"} — mark carried - -throw { code: 'READ_SCOPE_COMPILE_FAILED', status: 500, userMessage: '…' } - ③a analytics: 500 {"error":"Internal server error", - "code":"READ_SCOPE_COMPILE_FAILED"} — no mark - /data door : 500 {…, "userMessage":"…"} — mark carried - -throw Error('[Analytics] no strategy can handle query …') + userMessage - ③b analytics: 500 {"code":"ANALYTICS_QUERY_FAILED","error":"…"} — no mark - /data door : 500 {"code":"INTERNAL_ERROR","userMessage":"…"} — mark carried -``` - -Nothing invalid shipped — every body parsed as `ApiErrorSchema`, which already -declares the optional field — and that is what made the loss silent and -one-directional: a console told by ADR-0112 to render `userMessage` verbatim -found nothing at these three arms and fell back to its generic substitution, for -the same throw the twin door rendered. - -**What callers see change:** exactly one optional key is ADDED, and only when -the producer marked one. No existing key moves or changes value, at any of the -four arms — pinned as an explicit key-order assertion per arm for an unmarked -producer. - -The value comes from `boundedDeclaredUserMessage` (exported by #12693) — -`declaredUserMessage`'s presence answer with #5423's bound applied — resolved -once for the whole catch rather than at each terminal, so this door has one -answer to "is there a mark, and how long may it be" and shares it with `/data` -rather than copying it. ①b is deliberately untouched: a second application there -would be one rule applied twice. - -**Unchanged:** the prose withhold (#5367/#5437/#5811) — a declared server fault's -message is still replaced by the generic sentence and still reaches the operator -in full through the `logError` line that runs before every arm; the statuses and -`code`s all four arms answer; and #5667's tiering, which leaves a self-authored -undeclared fault readable. - -**Not reachable from in-repo producers today.** Censused at claim: no package -under `packages/services/**` sets a `userMessage` of any kind, and -`service-analytics` dispatches no sandbox hook, so the QuickJS side-channel — the -other in-repo carrier — does not reach this door either. This wires up a declared -channel the published contract already promises on this route's envelope; the -intended producer is an app author's analytics datasource or strategy. diff --git a/.changeset/analytics-measure-filter-crossobject-refusal.md b/.changeset/analytics-measure-filter-crossobject-refusal.md deleted file mode 100644 index b65f7c5a32..0000000000 --- a/.changeset/analytics-measure-filter-crossobject-refusal.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -`ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter. - -Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf. - -Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says. diff --git a/.changeset/app-plugin-artifact-forward-conversion.md b/.changeset/app-plugin-artifact-forward-conversion.md deleted file mode 100644 index 935005a2c9..0000000000 --- a/.changeset/app-plugin-artifact-forward-conversion.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -AppPlugin's bundle path runs the same ADR-0087 forward conversion as the artifact door - -On an artifact boot the stack-declared security metadata (`positions`, -`permissions`, `capabilities`, `sharingRules`) reached the metadata registry -through two independent readers: the artifact door -(`MetadataPlugin._parseAndRegisterArtifact`), which replays the versioned -ADR-0087 forward conversion before its strict parse, and `AppPlugin`'s ADR-0057 -block, which registered the bundle from `loadArtifactBundle` raw. The two copies -of the same item therefore differed, and which one a consumer saw depended on -registration order. `AppPlugin` now consumes the door's own -`applyArtifactForwardConversions` policy, so both copies carry the canonical -shape for every key the conversion layer governs. diff --git a/.changeset/approval-escalation-enabled-default-flip.md b/.changeset/approval-escalation-enabled-default-flip.md deleted file mode 100644 index 8d55339245..0000000000 --- a/.changeset/approval-escalation-enabled-default-flip.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-approvals": minor ---- - -feat(spec,plugin-approvals): `escalation.enabled` defaults to `true` and the SLA sweep finally reads it (#12278) - -**BREAKING** semantic default flip on a published authorable key, shipped as -`minor` under the repo's launch-window convention for breaking changes. -Maintainer ruling 2026-08-27 (Option C), explicitly reversing the 2026-08-26 -"spec stays as declared" ruling with fresh analysis. - -`ApprovalEscalationSchema.enabled` declared `default(false)` while the -plugin-approvals escalation sweep never read the key: any escalation block -with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` -that silently approved requests their author had declared off the clock — -the ADR-0049 declared-but-unenforced shape, failing open. Worse, the -approval-node executor parses node config through the schema before -snapshotting it onto the request row, so the old default **materialized** -`enabled: false` into storage for every author who omitted the key, making -"authored off" and "defaulted off" byte-identical at the sweep site. - -One change, both halves: - -- **spec**: `enabled` now defaults to `true` (stays `z.boolean()`; no - tri-state). The feature-level switch is whether an `escalation` block - exists at all; within a block carrying `timeoutHours`, escalation is on - unless explicitly turned off — which is what the runtime, its eleven - behaviour tests, and every teaching surface have always meant. Declared in - `DEFAULT_CHANGES_BY_MAJOR` (17) and registered as the - `approval-escalation-enabled-default-flip` semantic migration entry. -- **runtime**: `runEscalations` skips a request whose snapshot carries an - explicit `escalation.enabled === false` — the declared switch is enforced. - Request snapshots created **before** the flip cutoff - (`ESCALATION_ENABLED_FLIP_CUTOFF_MS`, 2026-08-28T00:00:00Z) ride a - read-side legacy window and keep escalating exactly as they do today: their - stored `false` is overwhelmingly the old schema default materialized onto an - author who never wrote the key, every such stored row is escalating today, - and the window retires itself as pending requests drain — zero tenant rows - rewritten. - -Deployed metadata that omits `enabled` does not change behaviour (it -escalated before, it escalates after). What changes is that writing -`enabled: false` finally binds for newly opened requests — the declared -intent being honoured. To keep an SLA off, write `enabled: false`; to -escalate on timeout, an `escalation` block with `timeoutHours` is enough. - - diff --git a/.changeset/approval-recall-admin-override-arm.md b/.changeset/approval-recall-admin-override-arm.md deleted file mode 100644 index c2fd433cb0..0000000000 --- a/.changeset/approval-recall-admin-override-arm.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -fix(plugin-approvals): the `approval_recall` action shows for the #3424 admin override (#12716) - -`ApprovalService.recall` has admitted two callers since #3424 — the submitter, -and a platform/tenant admin releasing a stuck request — and `isOverrideActor`'s -own doc block names recall as one of the four override levers in so many words. -The declared action that reaches that endpoint did not agree: `approval_recall`'s -`visible` predicate was submitter-only, while its three siblings -(`approval_approve` / `approval_reject` / `approval_reassign`) each OR in -`record.viewer.can_override`. - -So recall was the one lever the override covers whose button never appeared. An -admin rescuing an approval routed to an unstaffed position could approve or -reject their way out — writing a decision nobody made — or reassign it, but -could not simply withdraw it. This is declared-vs-enforced drift in the less -usual direction: a capability the server grants that no UI entry exposed. - -`approval_recall`'s `visible` now ORs in `record.viewer.can_override`, spelled -byte-identically to the three siblings. - -Not a permission change: the service's authorisation set is untouched, and -`can_override` was already computed server-side for every viewer. - -**Pending-only, and enforced rather than asserted.** The new arm carries no -status test of its own — neither do the siblings — because the flag is already -status-scoped where it is computed: `attachViewers` sets -`can_override: row.status === 'pending' && isOverrideActor(...)`, ANDed, so the -flag can never be true off `pending` and the arm is pending-only in effect -however CEL groups the expression. The submitter's own `returned` (revise -window) arm is unchanged. Pinned in both directions, with the flag's own scoping -pinned against the real service on a genuinely `returned` row. diff --git a/.changeset/approval-recall-refusal-localized.md b/.changeset/approval-recall-refusal-localized.md deleted file mode 100644 index dde3b405d6..0000000000 --- a/.changeset/approval-recall-refusal-localized.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -fix(plugin-approvals): the non-submitter recall refusal renders through the -Operation Message Catalog instead of a hardcoded English sentence (#11993, the -services-side half of the shape-A ruling) - -A user who opened a record someone else had submitted for approval, clicked -Recall and was correctly refused read the reason in English regardless of their -own locale. `@objectstack/rest`'s `handleApprovalError` ships this service's -thrown reason as the 403 body's human-readable `error`, and Console splices it -under its own localized label — so an operator in a fully Chinese deployment -read a Chinese prefix glued onto an English sentence they could not act on -(`撤回审批失败: `). - -The refusal now renders through the shared Operation Message Catalog in -`@objectstack/spec/system` under the key `approval_recall_not_submitter` that -#12493 landed for it — the same mechanism `plugin-security`'s denial gates -already use, with the same resolution ladder (deployment override → the -caller's locale → `en` → the key) and the same guarantee that a misbehaving -i18n service cannot turn a 403 into a 500. All four platform locales (`en`, -`zh-CN`, `ja-JP`, `es-ES`) ship copy that names who *can* recall, rather than -dead-ending the reader. - -`ApprovalServiceOptions` gains an optional `messageTranslator` — a lazily -resolved, `II18nService.t`-compatible lookup, wired by `ApprovalsServicePlugin` -the same way `tenancyPosture` and the field-visibility source are, because the -i18n service is contributed by another plugin and may start later. It is what -makes the override address the catalog documents, -`errors.approval_recall_not_submitter`, actually take effect for this emitter; -a stack without an i18n service still renders the built-in catalog in the -caller's locale. - -**Not changed: who may recall an approval.** The gate is byte-identical — the -submitter, or a privileged admin releasing a stuck record (#3424). Only the -sentence the refusal carries is different, and the `FORBIDDEN:` code prefix -that the REST layer maps to 403 is untouched. - -The button-visibility half of #11993 — a non-submitter seeing a live recall -button at all — is not addressed here; see the issue for the measurement. diff --git a/.changeset/approval-snapshot-docstring-audit-evidence.md b/.changeset/approval-snapshot-docstring-audit-evidence.md deleted file mode 100644 index 27233643f2..0000000000 --- a/.changeset/approval-snapshot-docstring-audit-evidence.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Correct what `sys_approval_request.payload_json` is documented to be FOR — it is audit evidence served redacted per reader, not a notification source - -The object's module docstring — which ships to consumers in the package's type -declarations — justified the snapshot column with: *"used by notifications so -they can render before the record is locked or changed."* That consumer does -not exist. Measured against every `this.notify(...)` call site in -`approval-service.ts`, all **12** of them, each passes a payload of -`{ title, message, actionUrl }` (two also carry `actions`), built from -`object_name` / `record_id` and the caller's own comment. **None** reads -`payload_json` or the parsed `payload`. - -This is more than tidiness: that sentence was the only documented -justification for the column holding a *full* row, and it was cited as such -during the #10749 consumer inventory before anyone checked it. The docstring -now states the real reason — the snapshot is retained as **audit evidence of -what was actually submitted**, so the column stays whole at rest, and is served -**redacted per reader** by the subject object's field-level read controls via -`getReadableFields`, on the approvals-inbox door and the generic data door -alike (#11039). - -The field's own `description` is deliberately unchanged: `Record snapshot at -submission time` is accurate, and it — unlike the JSDoc — is the string -extracted into the four generated i18n bundles, so no translation leaf moves -and no locale is left holding an English seed. - -Also carried in the same pass, the residual documentation the #10749 closure -assigned to the next docs touch in this lane: `payload-redaction.ts` recorded -`hidden`-vs-serialization as an **open** `packages/spec` question, and it has -since been ruled (maintainer, 2026-08-24, applying the 2026-08-12 lineage). -That paragraph now states the ruling — **`hidden: true` stays UI-only; -`internal: true` is the serialization primitive** — so an author who needs a -field kept out of read results is pointed at `internal: true` (#7728, -ADR-0049) rather than at `hidden`, which never governed serialization. - -Documentation only: no runtime behaviour, no schema field, and no public type -signature changes. diff --git a/.changeset/approver-org-scoped-team-justification.md b/.changeset/approver-org-scoped-team-justification.md deleted file mode 100644 index c2dd3d0b2e..0000000000 --- a/.changeset/approver-org-scoped-team-justification.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** `APPROVER_ORG_SCOPED`'s docblock stops justifying `team: false` with "the engine never scoped it", which the team organization screen made false (#10548). - -The docblock over `APPROVER_ORG_SCOPED` carried two clauses about `team`, and after #10230 they no longer agreed. "`sys_team_member` carries no organization column" is still true — it is still why a team's *members* are not individually placed. "the engine never scoped it" no longer described the engine: `expandTeamUsers` (`packages/plugins/plugin-approvals/src/approval-service.ts`) opens with `teamIsProvablyOutsideOrg`, which reads `sys_team`'s `organization_id` and drops the team when it names an organization other than the request's, at both of its call sites. - -The **flag value is unchanged and deliberately so**. The table answers ADR-0105 D9 *retargetability* — "does an `organization:` declaration apply to this type" — and `team` still consults no org-scoped directory, so a declaration on it still has nothing to redirect and is still rightly refused by `resolveApproverDirectoryOrg`. Only the justification had drifted, by resting on an engine behaviour that has since changed. The replacement text says the flag is about **targeting, not tenancy**: org-agnostic for retargeting because no directory is consulted, and screened to the request's organization on the team's own `organization_id` regardless. - -The risk repaired is the ordinary one for a load-bearing comment: the next reader deciding whether `team` needs an organization screen would find a spec docblock asserting the engine has none and conclude the work is outstanding when it has landed — or read the `team: false` / `manager: false` pairing as still marking "the unscreened types", which after #10153 and #10230 it does not. - -Prose only: no schema shape, no `.describe()` text, no runtime behaviour, and no authorable-surface movement. It is graded rather than skipped because the text ships to consumers on **two** surfaces — `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts`, so the docblock travels in the npm tarball as source, and unlike a property-level comment inside a `z.object({ … })` literal, a docblock over a top-level `export const` survives declaration emit: the stale sentence is present in the built `dist/automation/index.d.ts` and `dist/automation/index.d.mts`, which is what a consumer's editor surfaces on hover. diff --git a/.changeset/artifact-door-registers-capabilities.md b/.changeset/artifact-door-registers-capabilities.md deleted file mode 100644 index ea6787c59d..0000000000 --- a/.changeset/artifact-door-registers-capabilities.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -feat(metadata): the artifact door registers stack-declared `capabilities` (#12892 step 1) - -`ARTIFACT_FIELD_TO_TYPE` — the map that decides which collections of a compiled -artifact reach `MetadataManager` — now carries `capabilities: 'capability'`. -This is step 1 of the maintainer's 2026-08-29 ruling on #12892 (option 1: *the -door owns the registration route* for the five artifact security collections). - -**FROM.** `capabilities` is an authorable top-level stack collection (ADR-0066 -D1), but the door did not map it while `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` -block did — making that block the collection's **sole registrar on an artifact -boot**, and it registers the raw bundle bytes with no strict parse, no schema -default and no ADR-0010 provenance. On a `bootstrap: 'artifact-only'` runtime -where `AppPlugin` does not run, a package's declared capabilities reached no -registry at all: `GET /meta/capability` answered **empty**, and -`bootstrapDeclaredCapabilities` seeded **no `sys_capability` row** for them. - -**TO.** The door registers them like every other mapped collection: strict -parse, schema defaults, ADR-0010 provenance. Measured on a real artifact-only -kernel boot with no `AppPlugin`, over a package declaring -`{ name: 'crm.export', label: 'Export CRM data' }`: - -- `GET /meta/capability` went from `[]` to one item carrying `scope:'platform'` - (the `CapabilitySchema` default) plus `_packageId` / `_packageVersion` / - `_provenance`; -- `sys_capability` went from 9 rows (platform-curated only) to 10 — the - declaration now materializes with `managed_by:'package'` and its `package_id`. - -**What this does NOT change, deliberately.** On the ordinary artifact boot -`AppPlugin` still registers `capabilities` and still runs last, so its unparsed -copy still wins the registry — measured byte-identical before and after this -change. Two registrars on one route is the interim state the ruling explicitly -permits while step 2 (that block stops registering the five on the **artifact** -path, after a census of the non-artifact boots that depend on it) lands. No -authoring surface moves, and no artifact that parses today stops parsing. diff --git a/.changeset/artifact-forward-conversion-door.md b/.changeset/artifact-forward-conversion-door.md deleted file mode 100644 index f371df268b..0000000000 --- a/.changeset/artifact-forward-conversion-door.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/metadata-core': patch -'@objectstack/metadata': patch ---- - -Artifacts built by released 17.x tooling boot again on ≥17.2 runtimes: the artifact-ingestion door now runs a versioned ADR-0087 forward conversion before the strict parse (#12772). - -A compiled artifact whose declared `engines.protocol` floor predates the running `@objectstack/spec` version replays the full conversion chain — retired entries included — before validation, exactly the policy the stored-row read path already applies to `sys_metadata` rows. Measured incident: `dist/objectstack.json` built by `@objectstack/cli` 17.1.0 carries the then-legal `allowRestore`/`allowPurge` permission bits (75 of each, injected by the released builder), and spec 17.2.0's `retiredKey` tombstone refused the boot with no operator remedy (`os migrate meta` targets sources, not built artifacts). - -The conversion is versioned, not a blanket amnesty: an artifact authored at the current (or a newer) spec version converts nothing and still refuses at the tombstone — the retired keys return with the M2 lifecycle initiative (#1883), and artifacts authored against that surface are never stripped by history. Conversion notices surface operator-visibly and deduped, one summary line per conversion per artifact. New exports from `@objectstack/metadata-core`: `applyArtifactForwardConversions`, `resolveInstalledSpecVersion`, `parseRangeFloor`, `resolveDeclaredRange`. diff --git a/.changeset/artifact-registrar-policies-dead-pointer.md b/.changeset/artifact-registrar-policies-dead-pointer.md deleted file mode 100644 index 893719b7b8..0000000000 --- a/.changeset/artifact-registrar-policies-dead-pointer.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata": patch -"@objectstack/runtime": patch ---- - -fix(metadata,runtime): retire the `policies` dead pointer in both artifact registrars, and pin the map that carried it (#12894) - -Zero behaviour change, by construction. Both readers of an artifact boot carried -a `policies` -> `policy` entry — the artifact door's `ARTIFACT_FIELD_TO_TYPE` -(`packages/metadata/src/plugin.ts`) and `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` -list (`packages/runtime/src/app-plugin.ts`) — and **neither could ever match**. -`ObjectStackDefinitionSchema` is a `strictObject` that declares no top-level -`policies` key, so a definition carrying a `policies` array is refused outright -by the door's strict parse and reaches neither registry. The word is real but -lives one level down: on a permission set `policies` is an alias for -`rowLevelSecurity` (`PERMISSION_SET_KEY_ALIASES`) — a key on an **item**, never a -collection. Both entries are removed, each leaving in place the note the map -already writes for a retirement: what it pointed at, and why it could not match. - -That was the third entry retired from `ARTIFACT_FIELD_TO_TYPE` for exactly this -reason (`themes`, then `roles` -> `positions`, which "matched nothing and -silently dropped compiled positions"). So the deletion ships with the thing the -two predecessors did not have — a check that fails when the pattern recurs: - -- `check:stack-collection-maps` now reconciles **eight** hand-maintained - enumerations against the schema, not seven. `SECURITY_FIELDS` is the new - eighth, and how it was missing is the finding rather than a footnote: it is - the only one of the eight that pairs its keys as `[collection, kind]` tuples, - which neither existing extractor could read, so the site was skipped rather - than reported. Re-adding `policies` — or any other key the schema does not - declare — to **either** registrar now fails the gate with the site named. -- A new `tupleFirstItems` extractor reads that shape, with a self-test case - (13 assertions, up from 12) covering the comment/nesting cases the flat - string-array extractor already pins. - -The mirror-image half of the same measurement is **carried, not shipped**: -`capabilities` is a declared top-level collection that `SECURITY_FIELDS` -registers and the door's map does not, making `AppPlugin` its sole registrar on -an artifact boot. Adding it to the door changes what an artifact boot registers, -so it is measured and handed to the route-ownership decision (#12892) instead of -being smuggled in here. The new waiver row records the asymmetry in place. diff --git a/.changeset/artifact-unbound-form-predicate-root-notice.md b/.changeset/artifact-unbound-form-predicate-root-notice.md deleted file mode 100644 index 4909c8cbc1..0000000000 --- a/.changeset/artifact-unbound-form-predicate-root-notice.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/metadata-core": patch -"@objectstack/metadata": patch ---- - -feat(metadata-core,metadata): warn the operator when a pre-current-era artifact carries form-view predicates that fault open (#12915) - -A form-view predicate binds `record` (+ `previous`, `parent`) in runtime record -forms, or `data` in metadata-editing forms. The contract states the failure mode -beside the vocabulary: **a bare identifier is unbound, the predicate faults, and -`visibleWhen`'s fault fallback is `true`** — so a field the predicate was -authored to hide renders for everyone. - -That is quiet alone and lethal in combination with the authoring pattern it -serves. Measured on a real deployment: an artifact built by released -`@objectstack/cli` 17.1.0 authors -`{ field: 'disqualification_reason', required: true, visibleWhen: 'status == "unqualified"' }` -— the era's working spelling. On a 17.2 runtime the predicate faults open, the -conditionally hidden field renders, and its unconditional `required: true` -blocks **every** record creation through the console, while the same payload -POSTs 201 through REST. Nothing refused and nothing logged, so the operator — -the only person who can rebuild the artifact — had no signal at all. - -The framework artifact door now emits **one deduped `warn` line per artifact** -naming the authored `engines.protocol` floor and the runtime spec version, how -many predicates on which views (with the first path as an anchor), the -fault-open consequence, and the remedy (`os build`). It rides the same funnel -that already carries the forward-conversion summaries, so both SaaS shapes are -covered: a single-DB multi-org runtime warns once at boot, and per-tenant-DB -kernels each warn at their own. - -**No behaviour change.** No refusal, no rewrite, no schema or contract edit — -the predicate keeps faulting open exactly as before, and the artifact bytes are -untouched. Rewriting a bare root to `record.` is a separate, deferred ADR-0087 -conversion. - -**Scoped to legacy artifacts by construction.** The notice fires only inside the -versioned window the forward conversion already opens (declared floor below the -running spec, or undeclared), read off that pass's own verdict rather than -recomputed. An artifact declaring the current or a newer floor gets zero notices -from this feature even when it carries bare roots — the boundary that keeps a -notice about legacy artifacts out of contract territory. - -Detection is exported from `@objectstack/metadata-core` as -`detectUnboundFormViewPredicateRoots` (with `BOUND_FORM_VIEW_PREDICATE_ROOTS`) -so other composed artifact doors can reuse one policy rather than fork it. It is -pure, read-only, and tuned to prefer silence over a false accusation: string -literals are stripped before the scan, only root position counts, call targets -are not roots, comprehension macros (whose iteration variable is locally bound) -are skipped whole, and AST-only envelopes pass. diff --git a/.changeset/audience-posture-invite-only-default.md b/.changeset/audience-posture-invite-only-default.md deleted file mode 100644 index 1c4e98de32..0000000000 --- a/.changeset/audience-posture-invite-only-default.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-auth': minor -'@objectstack/verify': patch ---- - -feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` - -**BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. - -- New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. -- **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. -- `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. -- Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. -- `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. -- The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. -- `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). - - diff --git a/.changeset/auth-audience-settings-surface.md b/.changeset/auth-audience-settings-surface.md deleted file mode 100644 index 73fac60302..0000000000 --- a/.changeset/auth-audience-settings-surface.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/service-settings": minor -"@objectstack/plugin-auth": minor ---- - -feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) - -The audience posture shipped by #11739 (`invite_only | email_domain | open`, -default `invite_only`) was switchable only from stack config at boot; a -self-host admin had no console channel. The `auth` settings namespace now -carries an `audience` group — three new authorable keys, which is what a host -sees and why this is `minor`: - -- `audience_posture` — a select over the closed vocabulary (the option table - is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override - door); -- `audience_allowed_email_domains` — newline- or comma-separated bare domains - (exact, case-insensitive matching; subdomains need their own entries); -- `audience_self_registration_permission_set` — the `sys_permission_set` name - each self-registrant receives. - -`bindAuthSettings` maps the three keys — one atomic declaration — to one -`AuthManager.applyConfigPatch({ audience })`, which replaces the whole -audience object and validates the MERGED result. Every #11739 invariant holds -through the new channel: a self-registration posture with verification -explicitly off, an empty domain list under `email_domain`, and a missing or -`admin_full_access` permission set are all refused loudly (the standing -config keeps ruling — fail closed), and off-vocabulary postures are refused, -never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT -settings values apply: the manifest defaults never mask a deployment's -boot-config declaration. Switching back to `invite_only` always applies — -leftover text in the posture-hidden sibling fields cannot make closing the -wall refusable. diff --git a/.changeset/auth-settings-bind-window-ordering.md b/.changeset/auth-settings-bind-window-ordering.md deleted file mode 100644 index 95b24f82dd..0000000000 --- a/.changeset/auth-settings-bind-window-ordering.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Apply the workspace's SAVED auth settings at boot — `AuthPlugin` now declares -the settings ordering edge instead of reading in the pre-bind window - -`SettingsServicePlugin` registers the `settings` service in `init()` but binds -its DATA ENGINE from a `kernel:ready` hook it registers in `start()`. Between -those two moments the service is resolvable and answers reads — from an empty -in-memory fallback and the manifest defaults, with `source: 'default'` — while -the deployment's real `sys_setting` rows sit unread. Nothing distinguishes that -from "no row exists". - -`AuthPlugin` was reading inside that window. Its `start()`-registered -`kernel:ready` hooks reach `getService('settings')` at depth 3 (`runBackfill` → -`ensureAuthSettingsBound` → `bindAuthSettings`) and call -`getNamespace('auth')` in the same tick. Handlers fire in registration order, -registration order is `start()` order, and `AuthPlugin` declared -`dependencies: ['com.objectstack.engine.objectql']` and nothing about settings -— so nothing ordered it after the settings plugin. - -On the shipped composition that order was not merely unconstrained, it was -**wrong**: `os serve` does `kernel.use(new AuthPlugin(...))` before the -capability loop registers `SettingsServicePlugin`, and `resolvePluginOrder` -preserves insertion order for plugins with no edge between them. So everything -`applySettings()` derives was computed from DEFAULTS at boot — the ADR-0093 -membership policy the D6 backfill runs under, and the `google_*` social-provider -config. `settings.subscribe('auth', …)` only re-applies on a *later* change, so -a workspace that configured auth in Setup and never touched it again kept -booting with the wrong values: authored, stored, and silently not applied. - -The repair is one declaration, the same shape the three other shipped readers -(`plugin-email`, `service-sms`, `service-storage`) already carry: - -```ts -optionalDependencies = ['com.objectstack.service.settings']; -``` - -SOFT, not hard — a kernel with no settings service must still boot auth, and -`bindAuthSettings` already returns early when the service is absent. -`requiresServices` would not have done it: that asserts the service is -REGISTERED before `init()`, which it always is, and carries no `start()` -ordering. - -Enforced in both directions. `check:settings-bind-window` goes green with the -`com.objectstack.auth` entry **deleted** from its shrink-only ledger — deleting -it while the defect stood reproduces the finding, so the green is a measurement -rather than a suppression. And `auth-settings-ordering.pin.test.ts` resolves a -hostile registry that composes auth BEFORE settings, then removes the -declaration from a live instance and watches the order revert (ADR-0049: -a declaration nothing acts on is the defect, not the fix). diff --git a/.changeset/authz-cache-invalidation-substrate.md b/.changeset/authz-cache-invalidation-substrate.md deleted file mode 100644 index 0006b28ecc..0000000000 --- a/.changeset/authz-cache-invalidation-substrate.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/core": minor -"@objectstack/service-cluster": minor -"@objectstack/runtime": minor -"@objectstack/plugin-security": patch ---- - -feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) - -The substrate step (§10.3) of the accepted #11633 cross-request caching design -(maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation -machinery once, before the grants cache (#11967) that will consume it, so that -leg does not carry it. **Nothing here caches anything.** - -- **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine - middleware seam on every `insert` / `update` / `delete`, ahead of the whole - chain (and so ahead of any `isSystem` bypass a middleware applies). It - generalises the private counter `@objectstack/plugin-security` has carried - since #10757: the mechanism was always the engine's, and hoisting it lets a - second consumer share **one** signal instead of minting a parallel one that - watches a different set of writes. A seam rather than a list of call sites, - because a forgotten call site fails as silent over-permission and writing - through the engine is the only way to write at all — including better-auth's - own adapter. -- **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged - in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a - consuming cache carries is the correctness contract; this channel is not.** No - shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a - missed message is *expected*, the bridge stays out of the write path (a - publish failure is logged and swallowed, never awaited by the writer), and the - channel only moves the *typical* convergence from one TTL to one network hop. - That statement lives in the code at the channel, where a consumer reads it. -- **The boot-time posture statement** — non-optional by the ruling. Whenever a - grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no - cross-node invalidation bus, the deployment is told so at `warn`, every boot, - naming the window it accepted and the remedy. It is a statement, not a - refusal: a TTL-bounded per-process cache is a legitimate configuration. It is - said out loud because a silently-absent invalidation bridge is how a security - control gets disabled with nobody noticing (#4785). The in-process `memory` - driver counts as **no** bus — a cluster service exists on the shipped default - while fanning out to nobody, which is the case a "is a cluster service - registered?" check answers `yes` to and is wrong about. - -**Runtime behaviour is unchanged.** With no cache consumer the epoch has zero -subscribers, so nothing is published and nothing is invalidated; with the -shipped default TTL of `0` the bridge attaches nothing and logs nothing above -`debug`. The one composition change worth naming: `Runtime` now registers -`AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` -— that is not an oversight, it is the loudest case the posture check has, and -skipping it there would put the statement's absence exactly where the missing -bus is. - -`@objectstack/plugin-security` is a `patch`: its permission-set memo now reads -the engine's epoch when the wired engine exposes one and keeps its private -counter otherwise (test doubles, embeddings). The covered set of writes is -identical — the plugin's own middleware was already global — and it is now -identical *by construction* rather than by two files agreeing on which -operations count. diff --git a/.changeset/automation-toggle-deny-message.md b/.changeset/automation-toggle-deny-message.md deleted file mode 100644 index 4e71fdff5c..0000000000 --- a/.changeset/automation-toggle-deny-message.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a refused `POST /automation/:name/toggle` is told what it attempted (#11666) - -The enablement door refuses in its own words now. A caller without -`manage_metadata` that hit `POST /api/v1/automation/:name/toggle` was answered -with the refusal the three definition writes share: - -```text -before: Authoring automation flows requires the `manage_metadata` capability. -after: Enabling or disabling an automation flow requires the `manage_metadata` capability. -``` - -They were disabling a flow, not authoring one. The sentence was accurate about -the policy — #10243's ruling classified toggle into the `manage_metadata` -authoring write set — and it named a verb the caller did not use. - -⛔ **Copy only; no policy moved.** The accept set is bit-identical: the same -callers are refused on the same four routes, `POST /` / `PUT /:name` / -`DELETE /:name` keep the shared sentence they read correctly with, and the -envelope is untouched — `PERMISSION_DENIED` / **403** on every arm, as #11660's -pins and the ADR-0112 vocabulary assert. Nothing becomes newly accepted or -newly rejected. - -Shaped on this domain's own precedent (`SCREEN_READ_DENY_MESSAGE` beside -`RUN_READ_DENY_MESSAGE`, #7968): a second constant for a second question, -rather than a reworded shared one. Rewording the shared sentence to cover both -was considered and declined — it would degrade the message for the three -definition writes in order to fix one arm. Both sentences still satisfy #7450: -each names the capability that would admit any caller, and nothing about this -one. - -A client branching on the human-readable prose of a 403 (rather than on -`error.code`) is the only thing that can notice. diff --git a/.changeset/automation-toggle-manage-metadata.md b/.changeset/automation-toggle-manage-metadata.md deleted file mode 100644 index f20b153a89..0000000000 --- a/.changeset/automation-toggle-manage-metadata.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -**BREAKING (authorization):** `POST /api/v1/automation/:name/toggle` now requires the `manage_metadata` capability. A caller that holds a session but not that capability is answered **403 `PERMISSION_DENIED`** where it previously received **200** with the flow's enablement changed. - -This narrows what the API accepts, so it ships as `minor` with the breaking surface named rather than as a `patch`. - - - -**The exact surface that moves** - -| | before | after | -|---|---|---| -| authenticated caller **with** `manage_metadata` | 200, flow toggled | 200, flow toggled — unchanged | -| authenticated caller **without** it | 200, flow toggled | **403 `PERMISSION_DENIED`**, `toggleFlow` never entered | -| anonymous caller | 401 | 401 — unchanged, the #5519 floor still answers first | -| engine self-invocation (`isSystem`) | 200 | 200 — unchanged | - -Nothing else on the domain moves. The execution doors keep their posture: `POST /:name/trigger`, the legacy `POST /trigger/:name` and `POST /:name/runs/:runId/resume` are untouched, so ordinary members can still run the flows built for them. The reads are untouched. `GET /automation/_status` still serves enablement to any authenticated caller — this change is about mutating the bit, not observing it. - -**Why enablement joined the metadata write set** - -#10145 gated the automation definition writes (`POST /`, `PUT /:name`, `DELETE /:name`) and deliberately left `toggle` out in the open, because whether disabling a flow is authoring or operating is a product call. It was filed, measured over HTTP, and ruled on 2026-08-23. - -The measurement is why "it is engine state, so leave it" did not survive: **the enabled bit is not a row, so no organization wall scopes it.** `toggleFlow(name, enabled)` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads that same map with no caller and no organization, and the automation service is one instance per environment. On a real, non-degraded `isolated` posture, a tenant org owner without the capability — refused 403 by `PUT /meta/:type/:name`, `POST /automation` and `DELETE /automation/:name` at the same session — switched a shipped flow off, and an unrelated tenant in a **different organization** plus the platform admin both read it off, symmetrically in both directions. Disabling a shipped flow is functionally equivalent to deleting it for as long as it stays off, and `DELETE /:name` was already gated. Mitigating but not exculpating: the override is process-local, so a cold boot reads `enabled: true` again. - -**No new capability name was minted.** The change is one arm on the existing `isFlowAuthoringWrite` predicate in `packages/runtime/src/domains/automation.ts` — the #10145 author wrote that as a single function precisely so this ruling would be one edit rather than a fourth copy of the policy. Fail-closed by construction, exactly like its three siblings: an absent `executionContext`, an absent `systemPermissions` or an empty one all refuse, and the gate runs ahead of the body checks so a refused caller learns nothing about the toggle contract. - -**Migration.** A caller that toggles flows programmatically — `client.automation.toggle(name, enabled)` — must present a principal holding `manage_metadata`; the same capability its `create` / `update` / `delete` neighbours have required since #10145. No caller of this route was found in this repo, in the Console UI (`objectstack-ai/objectui`, which posts only `/trigger` and `/resume` and merely *displays* enablement), or in the example apps, so the expected migration surface is programmatic SDK callers rather than end-user UI. diff --git a/.changeset/autorestart-covers-thrown-health-checks.md b/.changeset/autorestart-covers-thrown-health-checks.md deleted file mode 100644 index 818cc68715..0000000000 --- a/.changeset/autorestart-covers-thrown-health-checks.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852) - -`PluginHealthMonitor.performHealthCheck` reaches its failure handling by two -disjoint routes, and only one of them could ever restart the plugin. - -A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`) -incremented `failureCounters`, cleared `successCounters`, and — once -`failureThreshold` consecutive failures accumulated — consulted `autoRestart` -and restarted the plugin. A check that **threw** took a separate `catch` block -that incremented `failureCounters` and stopped there: it never cleared -`successCounters` and never read `autoRestart`. Because `raceCheckTimeout` -rejects rather than resolving, every `timeout` overrun lands in that `catch`, -so a plugin that hung was marked `failed` and never restarted no matter how -many rounds passed or what `autoRestart` said. The severer of the two failure -modes was the one that could not trigger recovery. - -Both routes now funnel into one `recordFailedRound` step that owns the -counters, the `failureThreshold` comparison and the `autoRestart` decision, so -a thrown or timed-out check is restart-eligible on exactly the same terms as a -returned failure. - -The per-route *status* label is deliberately unchanged: a throw is still the -separate `failed` status applied immediately with no threshold, as -`content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and -the restart decision are shared — those are what `failureThreshold` and -`autoRestart` declare, and neither names a route. diff --git a/.changeset/backfill-orgless-platform-rows.md b/.changeset/backfill-orgless-platform-rows.md deleted file mode 100644 index 649624d726..0000000000 --- a/.changeset/backfill-orgless-platform-rows.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -**Ops:** a one-off, idempotent backfill for the platform rows the pre-#10101 writers stranded with no organization — dry run first (#11308). - -#10101 fixed the WRITERS: a `sys_approval_request` and a `sys_automation_run` are now stamped from the SUBJECT record's organization, with the acting context as the ruled fallback. It wrote nothing to existing rows, so the population produced before it persists — a **pending** org-less approval request LOCKS the record it is about while being invisible in every organization-scoped inbox, its own owner's included, and automation-run history stays unattributed. This is the repair for those rows, on the maintainer's 2026-08-23 ruling (direction 3). - -`packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` sweeps `sys_approval_request` (with its `sys_approval_action` / `sys_approval_approver` children, which move with their request) and `sys_automation_run`. It scans only rows whose organization column is unset, re-reads each row's subject at repair time — live record first, the write-time snapshot (`payload_json` / `context_json`'s `record`) second for a subject that has since been deleted — and stamps the platform row with the subject's own organization. - -**Dry run first, and by default.** `planPlatformRowOrganizationBackfill(engine)` reads only and returns a per-object report naming every row it would touch; `runPlatformRowOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install. - -**Rows whose subject is equally org-less are counted and named, never written.** The acting-context fallback the writers apply is not available to a repair — the acting context is gone — and inventing one stays vetoed. Those ids are reported so the population is checkable and stays visible. - -**`sys_api_key`'s divergence is preserved, not flattened.** Both the column read on a subject and the column written on a platform row are resolved from the registered schema through the shared `createRecordOrganizationResolver` (`@objectstack/metadata-core`), so a platform row ABOUT an API key is repaired from that object's stamp-only `active_organization_id` (limb 0, #8778) and the credential table itself is never written to. - -**Idempotent, and asserted rather than claimed.** Every scan is `WHERE IS NULL` and every write fills that column, so a repaired row cannot match again; the test suite runs the sweep twice and pins the second run at zero writes. - -Publishes no runtime code: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first. diff --git a/.changeset/backfill-zero-organization-is-not-ambiguous.md b/.changeset/backfill-zero-organization-is-not-ambiguous.md deleted file mode 100644 index fd1634260f..0000000000 --- a/.changeset/backfill-zero-organization-is-not-ambiguous.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): the seed-tenancy backfill stops reporting a duplicate-minting hazard on a zero-organization first boot (#12395) - -The `#8686` split diagnostic guarded on `organizationIds.length !== 1`, which folded -two opposite conditions into one loud warning. With **several** organizations the -owner of an untenanted row is genuinely underdetermined and the warning is right. -With **none** there is no second partition at all: every object runs exactly one -`__global__` counter, so the line's claim that the named objects "run two autonumber -counters and can mint the same `unique` identifier twice" was false precisely when a -fresh install read it. (The `organizationLastValue: 0` it reported alongside is the -split probe's `LEFT JOIN` finding no second row, not a second counter at zero.) - -Zero organizations is now its own state — `no-organization-yet`, named after and -matching the 0 / 1 / several line `objectql`'s `resolveSystemWriteOrganization` -already draws — logged at `info` rather than `warn`. It is not silenced: the split -is still reported, because the observation is real even though the hazard is not. -It self-heals at the first sign-up, when the `sys_organization`-insert handoff runs -the same repair against a settled database. - -Two things this deliberately does not change. An organization probe that **failed** -still takes the loud path and now says so — an unreadable probe returns the same -empty array as a genuine zero, and reading it as "no organizations yet" is the -confusion `objectql` fixed in `#9261`. And the repair threshold is untouched: data -is still modified on exactly `organizationIds.length === 1` and nothing else. - -The affected-object list is also now described as what it is — a snapshot taken when -the probe ran. The probe runs at `kernel:ready`, which a boot can reach while an -over-budget inline seed is still writing in the background, so a first boot can name -fewer objects than the settled database holds. diff --git a/.changeset/batch-capability-and-overlay-boot-seeds.md b/.changeset/batch-capability-and-overlay-boot-seeds.md deleted file mode 100644 index 0d6e7cb5fd..0000000000 --- a/.changeset/batch-capability-and-overlay-boot-seeds.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -**Perf:** the declared-capability boot seed and the environment permission-set overlay reconciler each pay ONE batched existence read instead of one per item, and stop re-writing rows that already match (#11096, #11097). - -Both were read-then-write reconcilers over a set known in full before their loop started, and both had the shape #10946 removed from the permission-set and position seeders next door: - -- `bootstrapDeclaredCapabilities` issued a `SELECT … WHERE name = ? LIMIT 1` per declared capability, then an `UPDATE` on its own row whether or not anything had changed; -- `reconcilePermissionSetProjection` projected every environment-scope `permission` overlay in a per-name loop, each iteration issuing its own existence `SELECT` inside `upsertEnvPermissionSet` plus an unconditional `UPDATE`. - -On a local file database these loops are invisible. On the remote libsql/Turso database every hosted environment runs, each leg is its own sequential HTTP request, and the capability set is typically the largest of the identity axes — it is the union of every capability every declared package contributes, not a count bounded by the number of permission sets. - -Both now hoist one chunked `{ name: { $in: [...] } }` read out of the loop through `buildExistingByName`, which keeps the tri-state judgement that makes hoisting safe: **a read that could not ANSWER is not the answer "none of them exist"**. A batched read fails for the whole set at once, so collapsing those two would make a boot during a brief outage try to re-create everything; the seeders now decline the names they could not read, and say so. - -**The write-skip is an equality test, and the reconciliation leg is pinned.** A row whose stored value genuinely differs still gets its `UPDATE` — a reconciler that skipped writes outright would show a perfect round-trip count while silently reconciling nothing, so every counting test added here is paired one-for-one with a drift test over the same fixture, and both pairs were ablated to confirm the drift half fails when the write is removed. - -Two behaviour repairs the write-skip REQUIRED, both on the environment door — not optional polish, but corrections the equality test itself demands, verified by ablation (each one made a specific test fail when reverted): - -- **`customized` is now compared, not just written.** The flag is provenance rather than definition, so `recordDiffersFromBody` deliberately does not compare it; skipping on the facets alone would have stopped maintaining a flag the Setup list badges on and the reset action reads. It gets its own comparison term, against the same `managed_by:'package'` condition the write uses. -- **A newly created environment-authored record is no longer born badged "customized".** The INSERT used to stamp the caller's raw overlay opinion (`!!customized`) while the UPDATE branch's rule stamps `false` for a non-package row — those two disagree for any fresh `managed_by:'admin'` row created while its overlay is still active. Before this changeset, that disagreement was invisible: every boot re-wrote every record unconditionally, so the very next reconciliation pass silently overwrote the wrong value back to `false`. Once writes are equality-gated, that disagreement stops being invisible and becomes a REAL, PERMANENT one-boot-late corrective `UPDATE` after every such creation — the "steady state" round-trip count is not actually flat without this fix. Confirmed on this branch: reverting it to `!!customized` fails `#11097 — env overlay reconciliation: round trips > does not grow the steady-state round-trip count` and `#11097 — drift STILL reconciles > only the DRIFTED overlay is written` (both start seeing a real `UPDATE` on the boot immediately after any overlay-backed admin row is created). - -`projectPermissionMutation` also syncs the in-memory evaluator registry on an unchanged record, not only on a write. That sync is not a database round trip, and the evaluator resolves permission sets registry-first — gating it on "a write happened" would have left a steady-state boot enforcing the stale declared body while the record and Setup showed the overlay. - -⚠️ **This is a behaviour change beyond the write COUNT**, flagged explicitly: today, a brand-new environment-authored permission set with no package baseline can be observed `customized: true` for the one boot between its creation and the next reconciliation pass (or, on the live write-through door, self-heals within the same request). After this changeset it is never observed `true`. The change is required for the round-trip fix's own steady-state claim to hold on this path — the two are not separable — but it is a resulting-STATE change, not merely a write-count change, and is called out here for that reason. - -⚠️ **No curve number is claimed for either axis.** The hosted `bootstrap-curve.mjs` rig lives in `objectstack-ai/cloud` and neither of these axes has ever been measured on it. What is established is that the code shape is the one measured at slope 4.0000 / R² = 1.000000 on the two sibling loops in #10946, and that the round-trip COUNT is now flat in the number of declared items — which is what the new tests assert, in counts, never in wall time. diff --git a/.changeset/batch-curated-capability-existence-read.md b/.changeset/batch-curated-capability-existence-read.md deleted file mode 100644 index c26f5d8cd1..0000000000 --- a/.changeset/batch-curated-capability-existence-read.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Seed the curated platform capabilities with ONE batched existence read, and stop -rewriting rows that already match - -`bootstrapSystemCapabilities` built its whole definition set in memory and then -issued a separate `SELECT … WHERE name = ? LIMIT 1` per definition, followed by -an `UPDATE` that fired whether or not `label`/`description` had changed. On a -local file database that loop is invisible; on the remote libsql/Turso database -every hosted environment runs, each leg is its own sequential HTTP request, -competing for the same boot request budget as everything else. On a stock -installation that is 8 reads plus 8 writes, every `kernel:ready`, to store bytes -already there. - -The curated half's existence read is now one batched `$in`, and the reconcile is -equality-gated. On a steady-state rebuild the curated half costs **1 round trip** -instead of 16, and the write gate sits after the derived-ownership guard, so it -removes the redundant `UPDATE` from **both** halves. - -**The #8470 predicate travels inside the batched query, not applied to its -answer.** The curated half does not ask "is there a row with this name" — it asks -for the platform's own organization-less row (`managed_by: 'platform'` + -`organization_id: null`), and since `sys_capability.name` became unique per -ORGANIZATION those are different questions. Batching the wide question and -filtering afterwards reads every organization's row for every curated name — a -set bounded only by the number of organizations — against a page capped at one -row per name, so the page truncates, and a truncated page reads as "absent", -which inserts. Both harms are pinned as tests rather than argued: without the -predicate the shared name resolves to an organization's row, and two curated -names whose platform rows demonstrably exist come back absent. - -**An unreadable database now declines instead of guessing.** Hoisting a read out -of a loop changes what a failure means: per item a failed read fell through to an -insert the unique index refused, for that one name; batched, one failure speaks -for the whole set. `unknown` is therefore never read as "absent" — the affected -definitions are left entirely alone, counted in the new `unreadable`, and warned -once. This also retires a misdiagnosis: an unreadable database used to make this -half attempt an insert per curated name and then report a `blockedCurated` -collision for each, describing a blocking row nobody ever saw. - -`CapabilitySeedResult` gains `unchanged` and `unreadable`. Reporting "wrote -nothing because nothing differed" separately from "wrote nothing because the -writes stopped working" is what keeps the round-trip count from being satisfiable -by an implementation that simply stopped reconciling. - -**The derived half keeps its per-item read**, and not because it is the smaller -one — it is the half that grows. Its lookup is cross-organization by -construction, and `skippedAuthored` and the `platformStampedInOrg` anomaly signal -are computed from the lowest-id row installation-wide; narrowing it to the -platform bucket answers a different question and would silently reverse part of a -maintainer ruling, while batching it unnarrowed needs an unbounded read. Filed -rather than taken. - -No speedup is claimed. The hosted boot-curve rig lives in another repository and -its axes are permission sets / positions / objects, not this one. What is -established here is the round-trip count and the identity of the row each leg -reads and writes, both pinned in-repo. diff --git a/.changeset/batch-validateonly-tombstone-version-anchor.md b/.changeset/batch-validateonly-tombstone-version-anchor.md deleted file mode 100644 index 26cb68a3a2..0000000000 --- a/.changeset/batch-validateonly-tombstone-version-anchor.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): anchor the `BatchOptions.validateOnly` tombstone to the release that removed it (#13020) - -Published guidance text only — no accept-set movement. `options.validateOnly` -is still retired, still typed `never`, and still rejected with the same -prescription; the prescription now cites **which release removed it**. - -The tombstone read `… was removed from BatchOptions in @objectstack/spec.` — -a removal with no durable citation. Of the 132 audited tombstone prescriptions -it was the only one left without any: no ADR id, no `os migrate` command, and — -uniquely — no version anchor either. `retiredKey`'s own contract asks for one -("State what replaced the key, **the version that removed it**, and the -one-line fix"), and 128 siblings carry it. - -The version is READ, never recalled — three independent sources agree on -**17.0.0**: - -| source | reading | -|---|---| -| `packages/spec/CHANGELOG.md` | `ec796d5: feat(spec)!: retire \`BatchOptions.validateOnly\` …` under the `## 17.0.0` Major Changes heading | -| `git log --follow -- packages/spec/src/api/batch.zod.ts` | commit `ec796d5fd`; earliest GA tag containing it is `@objectstack/spec@17.0.0` (no pre-17 spec tag carries it) | -| `packages/spec/spec-changes.json` | `batch-options-validate-only-retired` → `toMajor: 17` | - -The nearest-heading method that produced the changelog reading was run against -two positive controls in the same population first (`global_nav` from -`ACTION_LOCATIONS`, `IDataDriver.findStream`) — both resolve to `17.0.0`, and -both match the version anchor already written in their own source tombstones. - -Anchor spelling byte-matches the sibling convention in the same `api/` family -(`analytics.zod.ts`: `` `query` was removed from AnalyticsQueryRequest in -@objectstack/spec 17.0.0. ``). - -The generated API reference pages under `content/docs/references/` carry the -prescription verbatim and are regenerated by tooling, not by hand. diff --git a/.changeset/boolean-aggregand-ruled-answers.md b/.changeset/boolean-aggregand-ruled-answers.md deleted file mode 100644 index 1996e27e6d..0000000000 --- a/.changeset/boolean-aggregand-ruled-answers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`. diff --git a/.changeset/boolean-aggregands-numeric-min-max.md b/.changeset/boolean-aggregands-numeric-min-max.md deleted file mode 100644 index a36ad17e82..0000000000 --- a/.changeset/boolean-aggregands-numeric-min-max.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/driver-sql': patch -'@objectstack/driver-memory': patch -'@objectstack/driver-mongodb': patch -'@objectstack/objectql': patch ---- - -`min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. - -FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). - -The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. diff --git a/.changeset/break-glass-guard-self-service-target.md b/.changeset/break-glass-guard-self-service-target.md deleted file mode 100644 index b84f1c4d19..0000000000 --- a/.changeset/break-glass-guard-self-service-target.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -`/delete-user` no longer lets a body-supplied `userId` win over the resolved actor - -`/delete-user` is the vendor's self-service delete: its contract names no -target, the subject IS the authenticated caller. The break-glass -last-local-credential guard's target resolution on that route still preferred -a body-supplied `userId` whenever one was present, so any authenticated -caller could steer the guard's own refusal at a user other than themselves. - -The guard's target on `/delete-user` is now the resolved actor unconditionally -— `body.userId` is never consulted for that route, only as a prior fallback. -`/admin/remove-user` and `/admin/ban-user` are unaffected: target-naming is -their own contract and is untouched here. For every caller acting on -themselves, nothing changes — the same lookup runs and the same outcome -(refuse the last local credential, admit everything else) is returned. diff --git a/.changeset/broken-sweep-filter-not-detector.md b/.changeset/broken-sweep-filter-not-detector.md deleted file mode 100644 index 2a1c24938a..0000000000 --- a/.changeset/broken-sweep-filter-not-detector.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): the documented broken-sweep predicate is a first FILTER, not the detector (#12685) - -`patch`, and not empty: `sys_automation_run`'s field descriptions are shipped, -translated, operator-facing text — they are what an admin reads in Setup while -wiring an alert they will then trust for months. No counter, no schema and no -engine behaviour changes here; the run summary measured by #4354 is correct and -untouched. - -## The wrong claim - -`acted_count` advertised `selected_count > 0 AND acted_count = 0 AND -unmeasured_count = 0` as *the* broken-sweep signal, unqualified. Measured A/B on -one graph pair through the real engine — a healthy idempotent sweep (re-select -the same records, gate each one on "was this already handled") and a dead gate -(#4347's shape, the gate sitting in front of the lookup) — **both** report -`selected > 0, acted 0, unmeasured 0`. The predicate cannot make the one -distinction it was advertised to make. - -"Over N consecutive runs" does not rescue it either: the healthy steady state -trips it on *every* run for as long as the outstanding work stands, so it is -persistent rather than transient. Consecutiveness filters flapping, which is a -different failure. - -Why a wrong sentence here is worse than a wrong sentence elsewhere: a detector -that fires during normal operation gets muted, and a muted broken-sweep detector -is the same silence #4347 produced — with the added cost that it now *looks* -monitored. - -## What the descriptions say now - -- `acted_count` states the predicate as the **first filter** and names the - discriminator: a healthy skip is accounted for by a read the run performed - (the lookup the gate depends on shows `runs > 0` and `selected > 0` in - `summary_json.nodes[]`), while a dead gate skips just as often with nothing - behind it (`runs: 0`, or `selected: 0`). -- `skipped_count` points at the same fold — `gates[]` names which edge closed - and how often, `nodes[]` says whether the lookup behind it found anything. -- `unmeasured_count` keeps its own point (why the third clause exists) and now - calls the query a filter rather than an alert. - -The discriminating data was already shipped by #4354; nothing new is measured -and no detector is implemented in the platform. `run-summary.test.ts` pins the -pair as executable evidence: both shapes match the filter, and the per-node fold -separates them. `content/docs/automation/flows.mdx` carries the same correction -with the measured table and the two authoring shapes that make a sweep's signal -quiet in its healthy steady state. diff --git a/.changeset/broken-sweep-residue-first-filter.md b/.changeset/broken-sweep-residue-first-filter.md deleted file mode 100644 index 1c37193e0c..0000000000 --- a/.changeset/broken-sweep-residue-first-filter.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the last three broken-sweep mentions in `automation` become the first FILTER, and the misspelled-`effect` sentences state the direction the code measures (#13063) - -#12685 measured that `selected > 0 AND acted = 0 AND unmeasured = 0` cannot separate a -healthy idempotent sweep from a dead gate, and #12721 / #12722 / #12900 / #13068 rewrote -the surfaces that stated it as a detector. Each of those changes was fenced to the doc -blocks its dispatch named, so three mentions inside `packages/spec/src/automation` -survived — including one that ships in `.d.ts` and is what a platform author reads. - -Reworded to the shape the sibling surfaces now agree on: the predicate is the FIRST -FILTER and not a verdict, the per-node fold (`FlowRunSummary.nodes[]` / `gates[]`) is the -discriminator, and each clause keeps its own true point. - -- `ExecutionStepMetricsSchema`'s `unmeasuredEffect` rationale no longer says an - understated `0` "fires the broken-sweep alert on a healthy run until operators learn to - ignore it". That muting is not peculiar to an understated `0` — after #12685 the filter - selects every healthy idempotent sweep — so the block now states what a fabricated - count actually costs: an understated `0` puts a run that DID act inside the filter, an - overstated `1` keeps a run that acted on nothing outside it, and a faked `acted` is a - fact the per-node fold can only repeat rather than settle. - -Separately, and measured rather than ruled: the two sentences describing a misspelled -`effect` key stated their consequence backwards. Read forward, a lost `effect: 'writes'` -declaration means the `script` step reports no `unmeasuredEffect`, so the run reports -`selected > 0, acted 0, unmeasured 0` — which SATISFIES the filter. The run therefore -lands INSIDE the candidate set reading exactly like a dead sweep, rather than escaping -it; the declaration is what would have kept it out. That is how the same file's `@module` -block already stated it, and what `flow-function-effect.dogfood.test.ts` asserts end to -end. The `FlowFunctionDeclarationSchema` TSDoc and the author-facing `history` string in -its unknown-key message now say so. - -TSDoc and one error-message string only — no behaviour, no schema, no accept-set change. diff --git a/.changeset/build-json-advisory-parity.md b/.changeset/build-json-advisory-parity.md deleted file mode 100644 index b363ac7fd9..0000000000 --- a/.changeset/build-json-advisory-parity.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Carry the capability-provider (#3366) and package-docs (ADR-0046) warnings in -the `os build --json` payload, so its `warnings` list matches -`os validate --json` on the same tree - -`os build --json` reported a strictly smaller `warnings` list than -`os validate --json` did for the same stack. #11643 closed the gap for the -undeclared-authoring-key findings; two lists were still behind it — the #3366 -installable-provider hints (an unknown capability token, or a provider that is -absent but addable with `pnpm add`) and the ADR-0046 package-docs advisories. -A CI job gating on `os build --json` therefore read an empty advisory list for -a stack that names a typo'd capability and ships a doc whose frontmatter tags -were silently dropped, while the identical job gating on `os validate --json` -read both. - -Measured over one temp project at `origin/main` `589758d22`, both commands -exiting 0: - -``` -os build ⚠ requires: "zzz_unknown_capability_token" is not a known platform capability — check for a typo. - ⚠ src/docs/advparity_guide.md: Frontmatter `tags:` … is not a list this reader understands … -os validate --json warnings: [ {doc record}, {token,message}, "No apps or plugins defined …" ] -os build --json warnings: [] ← both lists dropped -``` - -`compile.ts` computed both and then rendered them **inside** the -`if (… && !flags.json)` print blocks, which put them structurally out of reach -of the payload: computed, then discarded, for the one audience `--json` exists -to serve. This is the fourth measured instance of that shape in these two files -(#10953, #11174, #11643), and it takes the established fix — hoist the -formatting to the computation site so one list feeds both faces and they cannot -report different sets. - -**Order and shape are mirrored from `os validate --json`, not chosen here.** -That payload reads `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, -...capProviderWarnings, ...structuralWarnings]`; `os build --json` now emits -that list minus its last member. Doc advisories ride as the issue records -`collectAndLintDocs` returns and capability hints as `{ token, message }`, -which is what validate ships for each, so a consumer reads one shape per class -from either command rather than learning two. - -**No new key.** Both lists land in the `warnings` key the payload already -declared — "the whole registry's advisory set, in the shape `os validate --json` -reports", as its own comment has always said. The payload's top-level key set is -unchanged and pinned as unchanged. - -**`structuralWarnings` is deliberately not included.** `os validate` derives -four structural advisories ("No objects defined", "No apps or plugins -defined", and two manifest ones) from `collectMetadataStats`; `os compile` -calls that same helper but computes none of them, in any face. That makes it a -missing computation rather than a dropped list, and whether a command that -writes an artifact should raise them is a judgment rather than a mechanical -port. It is split out as #11896 and pinned as the only remaining residue between -the two payloads, so the question stays visible and a fifth genuinely dropped -list cannot hide in the gap. - -Text output is unchanged. diff --git a/.changeset/build-json-failure-payload-warnings.md b/.changeset/build-json-failure-payload-warnings.md deleted file mode 100644 index fa6a2b8195..0000000000 --- a/.changeset/build-json-failure-payload-warnings.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os build --json` carries the computed advisory lists on every failure exit, not the success payload alone (#11772) - -**Machine-contract widening on the `--json` failure payloads.** A consumer that -today branches on `warnings` being ABSENT from an `os build --json` failure -payload sees a different shape after this change. - -## What was wrong - -The text face prints its advisory blocks before the gates that can stop the -run — the #11529 author-time advisories at step 3b, the #3786 undeclared -authoring-key findings at 3d — and both end in `— re-run with --json for the -full list`. But `warnings` lived on the TERMINAL SUCCESS payload only (plus, -for `ruleAdvisories` alone, the author-time-rules failure). On a tree with 60 -undeclared authoring keys *and* a package-docs error: - -``` -os build Undeclared authoring keys (60) … 50 rows … - … and 10 more … — re-run with --json for the full list -os build --json {"success":false,"error":"docs validation failed","issues":[…]} - ^ the 60 keys nowhere -``` - -The remedy the notice named returned a payload that did not contain the list, -and the author could not reach the withheld entries by any route until an -unrelated later failure was fixed — the "the remedy named is unreachable" -shape of #11643 and #11391. - -## Which exits gain the field - -All nine failure exits of `os build --json`. Six already had a payload of their -own; three more were found while enumerating (the filing card's table listed -six). `warnings` is now present on every one, alongside each exit's existing -keys, which are unchanged: - -| exit (step) | existing keys | `warnings` before | after | -| --- | --- | --- | --- | -| `strict-body: missing body` (2b) | `issues` | absent | `[]` | -| protocol parse failure (3) | `errors` | absent | `[]` | -| `author-time rules failed` (3b) | `issues` | `ruleAdvisories` | unchanged | -| `capability provider preflight failed` (3c) | `issues` | absent | rule + capability | -| `access matrix drift` (3e) | `changes` | absent | rule + key + capability | -| `docs validation failed` (3f) | `issues` | absent | all four lists | -| `--no-runtime-bundle` refusal (4b) | `error` | absent | all four lists | -| `runtime bundle failed` (4b) | `error` | absent | all four lists | -| thrown / caught (bottom) | `error` | absent | what the run had computed | - -The success payload is unchanged in content: its -`[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings]` -spread — `os validate --json`'s order minus its trailing `structuralWarnings` -— moved to a single `warningsSoFar()` site that every exit now reads, so the -member order cannot drift between exits. - -## What a consumer keying off its absence should do instead - -⛔ `warnings` is no longer a signal of which exit produced the payload. Read -`success` (and `error` / `errors`) for that; a consumer that inferred "this is -a failure payload" from a missing `warnings` must switch to `success === false`. - -⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no -advisories". It means **this run stopped before those advisories were -computed** — the two early exits above (`strict-body`, protocol parse) run -before any advisory step, so their list is empty by construction. A consumer -that needs the full advisory set for a tree must read it from a run that -reaches at least the gate that computes it, or from `os validate --json`. - -✅ `warnings` is always an array on every `os build --json` payload, success or -failure, so it can be read unconditionally — that shape constancy is the point -of the change (maintainer ruling 2026-08-25, option 1 of three; option 2, -"carry them only where the text face printed them", was rejected as the hardest -contract to declare). - -Advisories stay CARRIED, never recomputed: each list is still computed at -exactly the step that owns it, so an exit upstream of a step legitimately -reports that list empty and no failure path pays for a computation it did not -already do. diff --git a/.changeset/build-json-undeclared-key-parity.md b/.changeset/build-json-undeclared-key-parity.md deleted file mode 100644 index a313adf267..0000000000 --- a/.changeset/build-json-undeclared-key-parity.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Carry the undeclared-authoring-key warnings in the `os build --json` payload, -so its `warnings` list matches `os validate --json` on the same tree - -`os build --json` reported a strictly smaller `warnings` list than -`os validate --json` did for the same stack, and the missing members were -exactly the "your key was dropped at load" ones (#3786 / ADR-0087). A CI job -gating on `os build --json` therefore could not see the class of warning that -silently discards authored metadata — while the identical job gating on -`os validate --json` did. - -Measured over one temp project at `origin/main` `4ceae8ab0`, three faces of one -authored stack whose field carries an undeclared key nested in `visibleWhen`: - -``` -os build ⚠ Undeclared authoring keys (1) — dropped at load (#3786) -os validate --json warnings: [ {rule record}, "…zzzUnknownKey…" ] -os build --json warnings: [ {rule record} ] ← the dropped list -``` - -`compile.ts` computed the findings and then formatted them **inside** the -`if (… && !flags.json)` print block, which put them structurally out of reach -of the payload: computed, then discarded, for the one audience `--json` exists -to serve. `os validate --json` had this exact defect on its own face and fixed -it by mapping the findings through `formatUnknownAuthoringKey` at the -computation site; `os build` now does the same, so one list feeds both faces -and they cannot report different sets. - -**No new key.** The findings land in the `warnings` key the payload already -declared — its own comment has always said "the whole registry's advisory set, -in the shape `os validate --json` reports" — carried as formatted strings -beside the authoring-rule records, which is byte-for-byte the heterogeneous -shape `os validate --json` already ships. Consumers reading `warnings` off -either command now read one shape for one class of warning. The payload's -top-level key set is unchanged and pinned as unchanged. - -This also makes an existing promise true. The truncation notice added in -17.2.0 ends with "re-run with `--json` for the full list"; that pointer was -honest about the authoring-rule advisories and would have been false about the -undeclared-key list, which is why that change left the second list without a -notice. - -Text output is unchanged. diff --git a/.changeset/build-warning-truncation-notice.md b/.changeset/build-warning-truncation-notice.md deleted file mode 100644 index cce705900e..0000000000 --- a/.changeset/build-warning-truncation-notice.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os build` says how many author-time warnings it withheld, instead of stopping -dead at 50 - -The author-time advisory printer emitted a fixed 50 detailed entries and then -stopped, with nothing in the output saying the list had been cut. Measured on -`objectstack-ai/hotcrm` with the published 17.1.0 CLI: two `objectstack build` -runs over the same tree, before and after a five-warning fix, printed 50 -detailed entries each — 184 output lines and 52 warning lines both times — -while the summary line counted 80 and then 75. The two numbers disagreed and -nothing explained why. - -The defect is the **silence**, not the cap. Truncated output that carries no -notice is not merely incomplete, it is indistinguishable from complete: an -author who reads the report and sees their file is clean has read a list that -stopped early. Because advisories are ordered by surface (pages, then views, -then flows), a repo whose page warnings alone exceed the cap keeps every `view` -and `flow` advisory permanently invisible — and fixing warnings then makes new -ones *appear*, which reads as a regression caused by the fix. - -The cap stays, and over it the output now names the exact remainder: - -``` - ⚠ … and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list -``` - -At or under the cap no such line appears, and the detail entries themselves are -byte-for-byte what they were. The pointer is `--json`, which already publishes -the whole set under `warnings` — an existing complete-output path rather than a -new flag. No new verbosity tier, no paging, no configuration surface. - -`os validate` was checked at the same time and does **not** truncate its -advisory list: it prints every warning it collected. Only the `build`/`compile` -printer had the cap. diff --git a/.changeset/builtin-column-collision-warning.md b/.changeset/builtin-column-collision-warning.md deleted file mode 100644 index 6cd185c95d..0000000000 --- a/.changeset/builtin-column-collision-warning.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): name the storage a declaration on a builtin column name loses, instead of discarding it in silence (#12015) - -`initObjects` emits `id`, `created_at` and `updated_at` itself and then skips any -declared field colliding with one — `if (builtinColumns.has(name)) continue;`, with -no warning, no throw and no record anywhere that the author's declaration had been -dropped. Measured on live PostgreSQL 16.13: an object declaring -`id: { type: 'text' }` boots green and gets `id varchar(255)` — `table.string('id')`, -not TEXT. Measured here on SQLite: the same substitution, and a declared -`maxLength: 12` on that field binds nothing. The driver is right to own its primary -key and audit stamps; the defect was that it disagreed with the author in silence — -the declared-≠-enforced shape that bites hardest on AI-authored metadata, where the -mismatch surfaces much later as data behaving oddly. - -Every DDL path that drops such a declaration now says so, naming the field, the -object, the attributes that were lost and what the platform's column actually is: - -- **create** — `while creating table "…"`, said before the CREATE runs, so the - author hears it even when the CREATE goes on to fail for an unrelated reason; -- **ADD COLUMN diff** — `while syncing existing table "…"`; this path drops the - declaration for a different reason (the builtin is already in the table, so the - diff never proposes it), and it is the path a stock upgrade takes; -- **rotation shard** — `while syncing shard "…"`, covering both the shard-create and - shard-column-sync branches. - -A warning on one path with silence on the others just moves the trap, so each path -carries its own call and its own pin: a regression to a silent `continue` on one path -fails by name rather than being absorbed by a sibling. - -**Only the STORAGE half is reported, because only the storage half is lost.** A -declaration on a builtin column name still carries `label` (and the locales generated -from it), `readonly`, `searchable` and the ADR-0113 write contract in `required` — all -honoured on the platform's column exactly as on any other. So the diagnostic fires -only when the declaration asks for storage the platform's own column does not deliver -(a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a -`multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime', -defaultValue: 'NOW()' }` describes precisely what lands, and says nothing. -`id: { type: 'number' }` — an author expecting a numeric key — still fires. -`id: { type: 'text' }` does **not**: varchar(255) canonicalizes to the field type -`text`, so that declaration asks for precisely what the column delivers (#12131 — -the delivery table recorded the knex builder name `'string'` there at first, and -reported all 45 of the platform's own correct `id` declarations as disagreements). The storage/presentation split is one table -(`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key -added later is classified deliberately instead of defaulting into silence. - -**Grade: `patch`, and deliberately.** Nothing about the accept set moves — every -object that booted before still boots, the DDL emitted is byte-identical, no public -type or metadata key changes, and the only observable difference is a line in the log -for storage that was already being discarded. The platform still owns `id` / -`created_at` / `updated_at`: this changes what the driver **says**, never what it -**does**. diff --git a/.changeset/builtin-column-delivery-id-type.md b/.changeset/builtin-column-delivery-id-type.md deleted file mode 100644 index 21d27f1618..0000000000 --- a/.changeset/builtin-column-delivery-id-type.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/platform-objects": patch ---- - -fix(driver-sql): the builtin-column delivery table speaks the spec's field-type vocabulary, not knex's builder names (#12131) - -`BUILTIN_COLUMN_DELIVERY.id.type` recorded `'string'` — the **knex builder name** from -`table.string('id').primary()` — and `undeliveredStorageAttributes` compares that value -with `===` against a declaration's `type`, which is a spec `FieldType`. The two are -different vocabularies, and `'string'` is not a member of the one being compared: it is -absent from `FieldType`'s 49 options, `Field.string` is absent from the builder's keys, -and `FieldSchema` refuses `type: 'string'` outright. So **no declaration could ever -match it**, and the #12015 diagnostic reported every correct declaration on the -platform's own key as a disagreement. - -Measured on a stock boot of `@objectstack/platform-objects`: **45 warnings, one per -system object**, each saying `type: 'text' (the column is 'string')` about a -declaration that was right all along. `varchar` canonicalizes to the field type `text` -(`canonicalizeSqlType('varchar(255)') === 'text'`, `suggestFieldTypeForSqlType('varchar(255)') === 'text'`, -`isCompatible('varchar(255)', 'text') === true` — all pinned in `type-compat.test.ts`), -so `id: Field.text(...)` asks for exactly what the platform's column delivers. The -delivery table now records `text`, and the 45 lines go silent because they were false, -not because they were suppressed. - -`sys_migration.id`'s `maxLength: 128` was the one **honest** disagreement in that corpus -— the column is varchar(255) — and it is removed rather than widened to 255. It bound -nothing in any seam: the DDL discards a declared width on a builtin column name, and -`validateRecord` skips `id` by name on both the insert and the update path (it is also -`readonly`). Declaring a width that nothing enforces is the shape enforce-or-remove -exists to prevent, and the 44 sibling system objects declare none. - -The classification pin now holds **every** entry in the delivery table to -`FieldType.options`, so a builder name written there fails by name instead of surfacing -as a corpus of false warnings. The fixtures in both #12015 pin files were written -against the delivery table rather than against the source — `sys_presence.id` was spelled -`type: 'string'` in the "silent" cases, which is why they passed while the same -declaration as actually written warned. They now use the shapes as declared, and the -firing cases declare a type that genuinely disagrees. - -**Grade: `patch` for both, and deliberately.** No door moves and no DDL changes: the -platform still owns `id` / `created_at` / `updated_at`, the emitted column is -byte-identical, every object that booted before still boots, and `BUILTIN_COLUMN_DELIVERY` -is internal to the package (it is not re-exported from the package entry). The -`platform-objects` half removes one metadata key that was measured inert in every seam -that could read it. What changes is what the driver **says**. diff --git a/.changeset/bulk-write-refusal-message-parity.md b/.changeset/bulk-write-refusal-message-parity.md deleted file mode 100644 index dfb6ccced0..0000000000 --- a/.changeset/bulk-write-refusal-message-parity.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Serve a sandboxed hook's own refusal sentence on the bulk write routes, instead -of the QuickJS debug wrapper - -A hook's `throw new Error('删除被阻断…')` is a deliberate business rule, and -`classifyDataError`'s sandbox unwrap door exists precisely so the end user sees -only that sentence — the ` '' threw: ` prefix "belongs in -server logs", in the door's own words. Six write routes never reached the door. -Measured against the real route handlers: `PATCH /api/v1/data/:object/:id` -answered `Opportunity is closed.` while `POST …/batch`, `…/createMany`, -`…/updateMany`, `…/deleteMany` and `…/:id/clone` answered -`hook 'guard' threw: Error: Opportunity is closed.` — one hook, one refusal, a -different sentence depending on which route the caller happened to use. - -The branch is `resolveErrorResponse`'s declared-status passthrough, which is -checked *before* it delegates to `mapDataError` and answered its 4xx arm from -`error.message`. It now reads the business text through `sandboxBusinessMessage` -— the unwrap door's own two conditions (a non-empty string `.innerMessage`, and -not a `isScriptFaultMessage` crash) named once so the two doors ask the same -question. - -**Not a reorder.** The passthrough's own docblock argues the ordering: handing a -declared 5xx to `mapDataError` re-labels it from the message TEXT (the -overlay-delete fault comes back `404 OBJECT_NOT_FOUND` and stops being logged), -so the arm stays exactly where it is and keeps deciding the status. Only the -sentence it reads changes. #5437/#5582's unconditional 5xx prose withhold is -untouched — a sandbox refusal declaring a 5xx still answers with the generic -text, pinned on both spellings. - -What this restores is an invariant the same docblock already asserts. Its #7525 -paragraph says an error declaring `statusCode` instead falls to `mapDataError`, -"So the two doors already agree on the wire answer." For a sandbox refusal that -was false — `statusCode` was unwrapped and `status` was not — which is the -two-spellings asymmetry this card was filed on. The doors agree again, pinned -door-to-door across the whole 4xx band rather than asserted in a comment. - -**Bump level: `patch`, argued rather than defaulted.** The change is to message -TEXT on shipped routes, so the level is not automatic. It is a patch because -nothing about the envelope's contract moves: same status, same `code`, same -field set, no request newly accepted or refused. The delta is that one string -loses a debug prefix that this boundary already declares must never be on the -wire, and that the single-row routes never emitted — so no client could have -been reading it uniformly in the first place. Keying on the prefix would mean -substring-matching prose that is localised and deliberately reworded over time, -which is the practice the ADR-0112 `code` vocabulary exists to remove. - -`POST /api/v1/analytics/dataset/query` — the seventh row — needed its own -repair: it builds a `{ code, message }` envelope inline and touches neither -door. It now imports the same `sandboxBusinessMessage` rather than re-deriving -the unwrap, so the analytics face and the `/data` face cannot answer one refusal -two ways. Both of its client emissions are covered (the declared-4xx envelope -and the `500 ANALYTICS_QUERY_FAILED` fallback); `logError` still receives the -whole error and `looksLikeInternalErrorLeak` still reads the raw text, so the -operator's copy and the leak heuristic are untouched. - -`POST …/import` and `GET …/export` exit through `handleRouteError` like the bulk -routes, so they are repaired by the same change — measured rather than assumed. - -**Measured and deliberately NOT repaired here**, each recorded so it is not -rediscovered as new: the record-share routes (`…/:id/shares`, list/grant/revoke) -are a third branch again — `respondSharingError` classifies by -`message.startsWith(CODE)` and its fallback interpolates `error.message` into a -hand-built `500`, ignoring a declared `status`/`code` entirely. And on the -analytics route an *undeclared* hook refusal answers `500` where `/data` answers -`400`; only the sentence was corrected, the status disagreement is a separate -defect. Both are filed as their own issues. diff --git a/.changeset/button-icon-describe-shared-resolver.md b/.changeset/button-icon-describe-shared-resolver.md deleted file mode 100644 index cc45867d21..0000000000 --- a/.changeset/button-icon-describe-shared-resolver.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`element:button`'s `icon` description now names the shared icon resolver the renderer actually uses - -The `.describe()` on `ElementButtonPropsSchema.icon` — the sentence the docs site and the generated skill artifacts put in front of authors — said the renderer resolved the name "using its own PascalCase normaliser and rename map". That stopped being true when objectui moved the button off its file-local copy of that algorithm and onto the `resolveIcon` helper every `action:*` site already shared. The duplicate had been a defect in its own right: a rename added to the shared resolver to absorb a Lucide icon retirement reached every action site and silently missed `ui:button`. - -Nothing an author may write changed, and nothing about the platform's behaviour regressed. `icon` is still read, still rendered on either side of the label per `iconPosition`, still suppressed while `loading`, and an unknown name still resolves to nothing rather than degrading to a fallback glyph — which is still exactly what separates this slot from the `LazyIcon` path the container icons use. Only the sentence describing where the resolution lives had gone stale, and it is corrected here together with the cross-repo read-point anchors recorded beside it, re-measured at objectui `9602dc820450`. diff --git a/.changeset/changesets-repo-name-and-spec-badge.md b/.changeset/changesets-repo-name-and-spec-badge.md deleted file mode 100644 index 309e094839..0000000000 --- a/.changeset/changesets-repo-name-and-spec-badge.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec,repo): address the changesets `repo` option and the spec README's StackBlitz badge to `objectstack-ai/objectstack` (#12488) - -Two places still name `objectstack-ai/spec`, which is not the repository this -monorepo lives in: - -- `.changeset/config.json` — `changelog[1].repo` -- `packages/spec/README.md` — the "Try Online" StackBlitz badge target - -The README badge is the user-visible half. `README.md` is listed in this -package's `files`, so it ships in the npm tarball and every reader of -`@objectstack/spec` on npm gets a "Try Online" button addressed to a repository -that is not this one. The path the badge names — -`examples/app-todo/objectstack.config.ts` — exists here, so correcting the -owner/repo segment is the whole fix. - -The `.changeset/config.json` half is hygiene with a measured expiry date rather -than a bug that is firing. The configured generator `@changesets/cli/changelog` -re-exports `@changesets/changelog-git`, whose `getReleaseLine(changeset)` and -`getDependencyReleaseLine(changesets, dependenciesUpdated)` do not read the -third argument — the options object that `@changesets/apply-release-plan` -passes as `config.changelog[1]`. So `repo` has **no reader today**, and the -wrong value has never produced a wrong link in any CHANGELOG this repo has -generated. It acquires a reader the moment anyone swaps in -`@changesets/changelog-github` — which is the usual reason to touch that block -— and from then on every generated release line would link into the wrong -repository, with nothing in the diff looking wrong. - -No runtime behaviour changes. diff --git a/.changeset/cli-auth-command-examples-resolve.md b/.changeset/cli-auth-command-examples-resolve.md deleted file mode 100644 index d350d92842..0000000000 --- a/.changeset/cli-auth-command-examples-resolve.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `register`/`whoami`/`logout` examples no longer spell `os auth ` in live `--help` output (#11221) - -`static override examples` is printed verbatim as part of oclif's `--help`. `register.ts`, -`whoami.ts` and `logout.ts` live at the **root** of `packages/cli/src/commands/`, so oclif's -pattern-strategy loader registers them as `register` / `whoami` / `logout` — but their -`examples` spelled an `os auth ` shape that has never resolved. A user copy-pasting -straight out of `--help` hit `Error: Command auth:whoami not found.` (exit 2), the same dead -command #10927 fixed in `packages/cli/README.md` and #10967 fixed for the `environments` -topic, this time on the root auth-family commands. - -Measured against the built CLI (`packages/cli/bin/run.js`) before the fix: `os auth whoami`, -`os auth register` and `os auth logout` each exited 2 with `Error: Command auth: not -found.`, while the bare `os whoami` / `os register` / `os logout` each exited 0 and printed -help — so the examples named the one spelling that could not work. All seven `examples` -entries now say the bare, registered spelling. - -The exported default class on each file is renamed to match its real, file-path-derived -command id (`AuthRegister` → `Register`, `AuthWhoami` → `Whoami`, `AuthLogout` → `Logout`). -oclif derives a command's id purely from its file path, never from the class name, so this -changes no runtime resolution — confirmed by rebuilding the CLI and re-running `--help` on -all three. The rename also brings them onto this package's measured convention: every other -root-level command class is exactly the PascalCase of its filename. `login.ts` keeps -`AuthLogin` — its `examples` were already correct (`$ os login`), so it is outside this -card's file surface; that lone remaining class-name holdout is reported, not swept. - -`environments.test.ts`'s `#10967` pin carried a deliberately self-retiring `EXCLUDED` entry -for each of these three files, asserting the defect was *still present* so the exemption -could not outlive its cause. This fix removed the last unresolved entry, that assertion went -red exactly as designed, and the three entries are retired — the map is now empty and all -three files are scanned by the main assertion like every other command source. diff --git a/.changeset/cli-auth-config-guidance-resolves.md b/.changeset/cli-auth-config-guidance-resolves.md deleted file mode 100644 index 72afe18452..0000000000 --- a/.changeset/cli-auth-config-guidance-resolves.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the "no stored credentials" error stops telling a stuck user to run `os auth login` (#11313) - -`readAuthConfig()` (`packages/cli/src/utils/auth-config.ts`) throws the one instruction a user -gets at the moment they are **already stuck**: they have no stored credentials, the command -they wanted has just failed, and this string is what tells them how to recover. It said -`os auth login`, which does not resolve — `login.ts` sits at the **root** of -`packages/cli/src/commands/`, so oclif's pattern-strategy loader registers it as `login`, and -no `auth` topic has ever existed. The second failure reads as "the tool is broken", not as -"a typo in a help string", which is why this is graded on its own terms rather than as another -stale-`examples` docs nit (#11221, #10967, #10927 were all `examples` arrays and README prose -— a user reading *ahead*). - -Measured against the built CLI (`packages/cli/bin/run.js`, after building the package and its -dependency closure) before the fix: `os auth login --help` → `Error: Command auth:login not -found.` (exit 2), `os login --help` → exit 0. Loading the built oclif `Config` enumerates 61 -registered ids, **zero** containing `auth`, and no `auth` topic — with `login`, `logout`, -`register`, `whoami`, `dev` and `serve` all present as the control that the zero is a real -absence rather than a broken probe. The message now says `os login`, the spelling `login.ts`'s -own `examples` already used. - -One further invocation in the same file is corrected in the same pass: -`AuthConfig.activeEnvironmentId`'s doc comment said `os projects switch`, and `projects` was -renamed to `environments` in v5.0 with no aliases (ADR-0006) — the same enumeration shows no -`projects` topic and no id containing `project`, while `environments switch` is registered. It -is fixed rather than excluded because the new pin scans the whole file, and an exclusion is how -a line stops being checked without anyone deciding to stop checking it. - -The pin (`packages/cli/src/utils/auth-config.test.ts`) asserts the **property**, not the new -spelling: every command invocation this file documents must resolve to an id the CLI actually -registers, with the id set re-derived from `src/commands/**` using oclif's own path→id -algorithm. A pin on the literal text `os login` would still pass on the day someone renames -`login.ts`; this one reds. It has two legs — the real `readAuthConfig()` driven into its real -ENOENT branch against a redirected `$HOME`, so what is checked is the message a user actually -reads, and a source-wide scan so a guidance string added to this file later is held to the same -property without anyone remembering to extend the pin. `environments.test.ts`'s `#10967` pin -reads `static override examples` via AST and structurally cannot see a thrown-error string, -which is why this class needed its own pin rather than an extension of that one. diff --git a/.changeset/cli-command-contribution-retired.md b/.changeset/cli-command-contribution-retired.md deleted file mode 100644 index 04f61d2fcf..0000000000 --- a/.changeset/cli-command-contribution-retired.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the orphan `CLICommandContributionSchema` export — the manifest surface it described is a tombstone (#12007, ADR-0049) - - - -**BREAKING** export removal, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` -(`kernel/CLICommandContribution`) + the D3 semantic entry -`cli-command-contribution-retired` — where `os migrate meta` users will -look). - -`CLICommandContributionSchema` described a "CLI Command Contribution -declaration in the manifest" and claimed to be "retained for backward -compatibility and for describing command metadata in plugin manifests" — but -after #10724 tombstoned `manifest.contributes.commands`, no manifest surface -could legally carry these entries: the exported schema advertised a shape -whose only declared carrier rejects it. The manifest never referenced this -schema even before the tombstone (its inline `commands` item schema was an -independent duplicate), and it had zero consumers outside spec's own test and -generated artifacts, measured at the retirement's base commit with positive -controls in objectstack, objectui (pinned sha) and cloud — the exported -orphan-value-schema class (#3950: an exported schema with no consumer reads -as a capability). - -FROM → TO: - -- `CLICommandContributionSchema` / `CLICommandContribution` → *(removed — no - declarative replacement, because no declarative surface ever carried it)*. - CLI commands are registered through oclif's native plugin discovery: the - plugin package declares an `oclif` section in its own `package.json` — - `OclifPluginConfigSchema` / `OclifPluginConfig` (same module) describe that - live surface and survive unchanged. - -One-line fix: delete the import (nothing ever read the declaration); if you -describe a plugin's CLI commands, declare the `oclif` section in the plugin's -`package.json` — `OclifPluginConfigSchema` validates it. - -The retirement kit: - -- whole-def deletion (route 3 — no carrier key, no authored document, so no - tombstone and no D2 conversion; the #11825 / #8715 shape): - `kernel/CLICommandContribution` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 - semantic entry `cli-command-contribution-retired` -- pin test (`kernel/cli-command-contribution-retirement.test.ts`): zero - holders for both retired names on every public entry, survivors pinned - (`OclifPluginConfigSchema` / `OclifPluginConfig` — the live `package.json` - `oclif` surface) -- the module docblock's Commander.js migration prose is KEPT — it is cited by - the `contributes.commands` tombstone (full-file deletion was explicitly not - the shape) -- zero authored occurrences in objectstack, objectui or cloud (measured at - dispatch, re-verified at claim), so no in-repo source changes ride along diff --git a/.changeset/cli-environments-entity-noun.md b/.changeset/cli-environments-entity-noun.md deleted file mode 100644 index e9b24d5777..0000000000 --- a/.changeset/cli-environments-entity-noun.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the `os environments` family calls the entity an environment, not a project, in every string it prints (#12153) - -Per ADR-0006 the v5.0 rename `project` → `environment` has no aliases, and AGENTS.md -states "Project now only means the npm/monorepo sense". #10967 (PR #11227) renamed the -**command** (`os projects …` → `os environments …`) across these same five files; the -**entity noun** inside the strings oclif prints was left behind. A user ran -`os environments switch ` and the tool answered `✓ Active project: …`. - -25 user-visible string literals in `packages/cli/src/commands/environments/` are swapped -to the post-rename noun. No behaviour, no flag or argument names, no exit codes, and no -`--format json` / `--format yaml` payloads change — those are produced by -`formatOutput(res, …)` straight from the control-plane response and are untouched. - -| where | count | printed by | -| --- | --- | --- | -| `static override description` | 5 | `os environments --help` | -| flag / arg `description` | 7 | each command's own `--help` | -| success-path console output | 9 | `list` · `bind` · `create` · `show` · `switch` | -| `examples` arg placeholder (`` → ``) | 3 | `os environments bind --help` | -| the `switch` id-not-found error | 1 | `os environments switch` on a bad id | - -The five `static override description` strings now read as -`content/docs/deployment/cli.mdx`'s command table has described them since the rename -("List environments visible to the current session", "Provision a new environment", …), -so the shipped `--help` and the shipped docs agree for the first time. - -What is deliberately NOT renamed, because it is API surface in other packages rather than -CLI wording, and each needs its own decision: `client.projects.*` (the `@objectstack/client` -SDK method names), the `res.project` / `res.projects` response fields, the locals bound -directly from them, and the docblock comments in these files. diff --git a/.changeset/cli-i18n-flow-screen-bucket.md b/.changeset/cli-i18n-flow-screen-bucket.md deleted file mode 100644 index b4ee112ede..0000000000 --- a/.changeset/cli-i18n-flow-screen-bucket.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os lint` and `os i18n extract` gain the flow/screen bucket — a screen-flow -copy gap is reportable, and the `flows..screens..` skeleton is scaffoldable, -for the first time - -#11287 gave the bundle a `flows` group and a resolver that applies it. Nothing -on the CLI side walked it: `COVERAGE_SOURCE` had no flow bucket, so the -`i18n/missing-*` family could not report a screen-flow copy gap **at all**, and -`os i18n extract` never wrote the keys, so an author had no way to discover the -vocabulary. Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on -a tree whose six screen dialogs rendered English in all four locales. An app -whose i18n gate is green is green because the surface is invisible to it. - -The shared walker (`collectExpectedEntries` — one definition of what is -translatable, feeding both the gate and the extractor) now harvests, per flow: - -``` -flows..label -flows..screens..title -flows..screens..fields..label -flows..screens..fields..placeholder -``` - -Screens are keyed by `FlowNode.id` and fields by `ScreenFieldConfig.name` — the -identifiers the runner already holds at render time, not a second naming -scheme. Missing keys report as `i18n/missing-flow`, per locale, with the same -opt-in rule as every other bucket: a project that declares no locales and ships -no bundle still reports nothing. - -The copy keys are **imported** from `@objectstack/spec/system` -(`FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`), never restated. They -are exported precisely so the extractor and the resolver cannot drift; the -schema-to-list agreement is pinned spec-side in `translation.test.ts`, and the -list-to-walker agreement is pinned here. - -Two seeding rules follow what the reader actually sees rather than which key -the author filled in. A screen's `title` falls back to the node `label`, -because the executor builds the wire title as `config.title ?? node.label` and -one bundle key covers whichever the runner draws. A field's `label` falls back -to its `name` as a *derived* seed: the skeleton stays usable while the gate -demands no translation for a string nobody authored. diff --git a/.changeset/cli-json-failure-payload-conversions.md b/.changeset/cli-json-failure-payload-conversions.md deleted file mode 100644 index d9100bae33..0000000000 --- a/.changeset/cli-json-failure-payload-conversions.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os validate --json` and `os build --json` carry the computed `conversions` on every failure exit, not the success payload alone (#12125) - -**Machine-contract widening on the `--json` failure payloads.** A consumer that -today branches on `conversions` being ABSENT from an `os validate --json` or -`os build --json` failure payload sees a different shape after this change. - -## What was wrong - -`conversionNotices` is filled by the `onConversionNotice` sink handed to -`normalizeStackInput`, which runs at **step 2** — above the schema parse and -above every later gate in both commands. The notice was therefore already in -hand when any failure exit fired, and was then discarded: `conversions:` was -published on the terminal SUCCESS payload alone. - -An ADR-0087 D2 conversion notice is the one advisory class that carries an -**expiry** — `retiresIn` names the protocol major where the old shape stops -loading. So a CI job gating on `os validate --json` / `os build --json` could -not see that its tree depends on a conversion about to retire for as long as -the tree also tripped any unrelated gate — the notice was withheld exactly -while the tree was broken, which is when an author is most likely editing it. - -This is the same "computed, then dropped on a failure exit" shape as the -`warnings` family (#11643 / #11391 / #11772 / #12047), one field over. #12079 -added `warnings` to all nine `os build` failure exits and deliberately left -`conversions` untouched, so closing those cards did not close this one. - -## What changed - -`conversions` is now present on every `emitJson` exit of both commands — -6 in `validate.ts` (5 failure + success), 10 in `compile.ts` (9 failure + -success) — alongside each exit's existing keys, which are unchanged. - -| command | exit | `conversions` before | after | -| --- | --- | --- | --- | -| `os validate --json` | protocol parse failure | absent | the computed notices | -| `os validate --json` | author-time rules failed | absent | the computed notices | -| `os validate --json` | capability provider check | absent | the computed notices | -| `os validate --json` | package docs failed | absent | the computed notices | -| `os validate --json` | thrown / caught | absent | what the run had computed | -| `os build --json` | all nine failure exits | absent | what the run had computed | - -The success payloads are unchanged in content. - -Notices are **carried, not recomputed**: the fix is a pure scope change — the -sink array is declared above the `try` so the catch-all exit can read it — and -`normalizeStackInput` still runs at exactly step 2. A run that throws in -`loadConfig`, above step 2, therefore reports `[]` honestly. - -⭐ Note the two fields differ on `os build`'s two earliest exits. For `warnings`, -`--strict-body` and the protocol parse are empty by construction (nothing -advisory is computed that early); step 2 is **above** both, so `conversions` is -populated there. The field was measured per exit rather than inherited from the -sibling change. - -## What a consumer keying off its absence should do instead - -⛔ `conversions` is no longer a signal of which exit produced the payload, nor -of success. Read `valid` (validate) / `success` (build), and `error` / -`errors`, for that; a consumer that inferred "this is a failure payload" from a -missing `conversions` must switch to the explicit status field. - -⛔ `conversions: []` on a failure payload does NOT mean "this tree converts -nothing". It means **this run stopped before the conversion layer ran** — a -config that fails to load reports `[]` by construction. A consumer that needs -the true conversion set for a tree must read it from a run that reaches at -least step 2. - -✅ `conversions` is always an array on every `os validate --json` and -`os build --json` payload, success or failure, so it can be read -unconditionally — that shape constancy is the point of the change (maintainer -ruling 2026-08-25 on #11772/#12047, option 1 of three, applied here under the -same-family rule; option 2, "carry it only where the text face printed it", was -rejected as the hardest contract to declare). - -✅ Each entry keeps its structured fields — `conversionId`, `surface`, `from`, -`to`, `path`, `toMajor`, `retiresIn` — on failure exits exactly as on the -success payload, so a CI job can gate on `retiresIn` without a second run. - -Exit codes are untouched: every failure exit still exits 1. `--strict` on -`os validate` still reads the text face's own list, which folds conversion -notices in, so `os validate --json --strict` reaches the same verdict it did -before. - -`warnings` and `conversions` remain **separate fields**. Whether the two should -be folded into one is a live question raised on #12125 and not settled by the -ruling; this change deliberately mirrors the `warnings` shape rather than -merging either field into the other. diff --git a/.changeset/cli-metadata-stats-runtime-row-and-translations.md b/.changeset/cli-metadata-stats-runtime-row-and-translations.md deleted file mode 100644 index 0e56111803..0000000000 --- a/.changeset/cli-metadata-stats-runtime-row-and-translations.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Fix:** the `Runtime:` row of the `os validate` / `os info` / `os compile` summary is no longer dropped on a stack with no plugins, and `MetadataStats` no longer counts a metric nothing prints (#11172). - -Two separate holes in one function, `printMetadataStats` in `packages/cli/src/utils/format.ts`. Both were measured against the real CLI (`bin/run-dev.js validate`, `NO_COLOR=1`) on a stack declaring nothing. - -**1. `Runtime:` vanished at zero.** The row was rendered *outside* the `sections` loop, as a standalone `if (stats.plugins > 0 || stats.devPlugins > 0)` after the loop closed, so the whole summary was: - -``` - Data: 0 Objects - UI: 0 Apps - Logic: 0 Flows - Security: 0 Positions 0 Permissions -``` - -with no `Runtime:` line at all. That is the same "reads as never asked, not as zero" shape #10504 and #10952 removed from the four sections — a stack that declares no plugins is indistinguishable from a summary that simply does not report on the runtime. It now prints: - -``` - Data: 0 Objects - UI: 0 Apps - Logic: 0 Flows - Security: 0 Positions 0 Permissions - Runtime: 0 plugins -``` - -`Runtime:` was **folded into the `sections` array** rather than fixed where it stood. Being outside the loop was not incidental to the defect: it is why #10952's `zeroFallback` mechanism was structurally unable to reach this row, and a zero case hand-rolled beside the loop would have been a second, un-enforced copy of the same invariant — while `zeroFallback` is a *required* field on the array's element type precisely so the next row cannot be added without naming what it prints at zero. The per-item `> 0` filter this row already applied is the same filter the loop applies, so the only thing that had to be carried across was its fragment style, and it is carried exactly: the shipped non-zero rendering stays `Runtime: 2 plugins, 1 devPlugins` — comma-joined, fully dim, lowercase item names — rather than being restyled into the sections' ` ` two-space shape. The ruling was about the row's presence at zero, not its typography. - -`plugins` is the row's zero signal: `devPlugins` is a dev-only overlay on it, so `Runtime: 0 devPlugins` would have reported the narrower fact and stayed silent about the broader one. A row with one non-zero peer still reports only that peer (`Runtime: 4 devPlugins`), exactly as `Security:` behaves. - -**2. `translations` was counted on every run and read by nothing.** `MetadataStats` declared `translations: number` and `collectMetadataStats` populated it with `count(config.translations)`, but no render path ever read it — a stack with 40 translation bundles reported them nowhere in the summary, at *every* value rather than only at zero. The field is removed implementation-first (zero readers); giving it a rendered home, in `UI:` or a new `i18n:` row, was considered and explicitly not taken. - -The invariant that replaces it is enforced from both ends: TypeScript already requires `collectMetadataStats` to populate every field `MetadataStats` declares, and a new pin requires every field it collects to reach the rendered output. Declared ⇒ collected ⇒ rendered — a metric counted on every `os validate` and shown nowhere cannot satisfy the chain, whatever it is called, so the pin fails for the next unread metric as well as for this one. - -**One externally visible consequence beyond the summary text.** All three commands spread the whole `stats` struct into their `--json` payload, so `os validate --json`, `os info --json` and `os compile --json` no longer carry a `stats.translations` key. That field was undocumented (the CLI docs describe `--json` for these commands but declare no payload shape for `stats`), carried no schema, and has no reader anywhere in the repo — a repo-wide search for `stats.translations` returns zero consumers. The other 18 keys are unchanged. diff --git a/.changeset/cli-readme-drop-os-studio.md b/.changeset/cli-readme-drop-os-studio.md deleted file mode 100644 index cb373beb36..0000000000 --- a/.changeset/cli-readme-drop-os-studio.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): drop the `os studio` row from the README command table — the CLI ships no such command (#11180) - -`packages/cli/README.md`'s **Development** command table listed -`` | `os studio [config]` | Launch Studio UI with development server | ``. The CLI has no -`studio` command and has not had one: the oclif command set is pattern-derived from -`packages/cli/src/commands/**`, and loading the built CLI's own `Config` enumerates 60 -registered ids with **zero** matching `studio` (control: `dev`, `serve`, `login`, `logout`, -`register`, `whoami` are all present in the same enumeration, so the check is not vacuous). -Running it confirms the same from the outside — `os studio --help` exits 2 with -`Error: Command studio not found.` - -The row is deleted rather than rewritten. Studio is not reached by a CLI command at all — -it is served by the console at `/_console/studio` after `os dev` or `os serve`, both of -which the same table already lists — so a replacement row would reintroduce the category -error that made this one wrong: a Commands table is a list of commands, and a browser route -is not one. diff --git a/.changeset/cli-serve-anchor-optional-import-at-app.md b/.changeset/cli-serve-anchor-optional-import-at-app.md deleted file mode 100644 index 39a133bf79..0000000000 --- a/.changeset/cli-serve-anchor-optional-import-at-app.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os serve` resolves app-declared optional service packages from the app, not the CWD (#11185) - -`serve` takes its config as an **argument**, so `objectstack serve /srv/app/objectstack.config.ts` -is a supported invocation and the app being served need not be the directory the operator -stood in. Every host-anchored optional load nevertheless used `process.cwd()` as its -resolution base, so with that invocation the CLI read the wrong `package.json`: a package the -app really does declare, and really does carry in its own `node_modules`, came back -`undeclared`, fell through to the framework-side fallback, and boot died — - -``` -Cannot find package '@objectstack/service-cluster': the host app does not declare it. - host app: /tmp/os-neutral-cwd-jXHXdF ← the CWD, not the app - (fallback resolution also failed: Cannot find package '@objectstack/service-cluster' - imported from …/packages/types/dist/node.mjs) -``` - -Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate exits 1 ⇒ the -whole stack cannot start. This is the same class as cloud#1013 and #10645 with the base wrong -for a different reason: those fixed the **importer** at these load sites (bare `import()` → -`importFromHost`); this fixes the **base** that importer is handed. - -`serve` now resolves the config path and the app root in one call (`anchorServedApp`), so the -anchor cannot be written too late or left out by a future author — the absolute config path -every later line needs is produced by the same call that sets it. Every host-anchored load in -the file defaults to that root, which is what generalises the repair to the next app-declared -optional service rather than fixing this one instance. The alternative route — declaring -`@objectstack/service-cluster*` in `packages/cli`'s own manifest — was rejected: it would make -the open-core CLI take a published dependency on packages it never imports, still leave every -third-party or future optional service broken, and change nothing for an app whose config is -addressed by path. - -The adopted root is the config's directory **only when that directory holds a `package.json`**, -and the CWD otherwise. `readHostDeclaration` reads a manifest — reachability is deliberately -not the contract (#4719) — so a directory with no manifest declares nothing and anchoring -there could only turn a working boot into an `undeclared` refusal. No layout that resolves -today resolves differently after this. - -The #4719 declaration gate is untouched: a package present in the app's `node_modules` but -absent from its `package.json` is still refused. The refusal's remedy (`host app: …`) now -names the app being served instead of an unrelated directory the operator happened to be in. diff --git a/.changeset/cli-serve-exhausted-port-search-notice.md b/.changeset/cli-serve-exhausted-port-search-notice.md deleted file mode 100644 index 60e3eef15c..0000000000 --- a/.changeset/cli-serve-exhausted-port-search-notice.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os serve` says so when its port search runs out of ports, in the words the search threw (#12620) - -In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` walks -forward from the requested port looking for a free one. That walk gives up after -101 ports, and when it does it throws a message naming the problem exactly — -which the caller then discarded, fell through, and bound the requested port -anyway: the one port the search had *just proven* was taken. - -That boot died on the kernel's raw `EADDRINUSE` with no explanation anywhere. It -is the one shape in the whole port policy that reached **neither** half of this -family's legibility work: the production `Port … is already in use` line lives in -a branch this boot never enters, and the shifted-port notice (#12543) is printed -only when the bound port differs from the requested one — on this path they are -the same, because the search threw before it could assign. The accurate sentence -existed and was thrown away one line earlier. - -The fallthrough is unchanged and still deliberate — a developer whose whole next -span is busy arguably does want the requested port attempted rather than a hard -refusal, and whether it should refuse instead is a separate policy question -(#11113). What changed is that it is no longer silent: - -``` - ⚠ Could not find an available port starting from 32869 - Development auto-shift probed 101 ports (32869–32969) and every - one was busy, so this server is falling back to 32869 — the port the - search has just proven is taken. The bind that follows will almost - certainly fail with a raw EADDRINUSE from the kernel, and this notice - is the only place that says why. - Free a port in 32869–32969, or pick another via PORT= (or --port ). -``` - -The first line is the search's own thrown message, carried rather than -paraphrased, so there is one spelling of that fact and not two that can drift -apart. The width it reports is read from the same constant the walk uses, so the -range it names is always the range it actually probed. - -Written to **stderr**, like every other `os serve` diagnostic: `stdout` carries -JSON-RPC frames whenever the stdio MCP transport is mounted, so nothing but -protocol may go there. It prints only when the search is exhausted — an ordinary -auto-shift and a production boot are both unchanged, byte for byte. diff --git a/.changeset/cli-serve-host-importer-caller-base.md b/.changeset/cli-serve-host-importer-caller-base.md deleted file mode 100644 index bba1a4bb07..0000000000 --- a/.changeset/cli-serve-host-importer-caller-base.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Fix:** `os serve`'s host importer now states its own resolution base, so a package the served app does not declare resolves from `packages/cli` instead of from `@objectstack/types` (#11157). - -`createHostImporter` has two legs. The **declared** leg resolves out of the served app's `node_modules` (#4719; #11185 fixed *which* app that is). The **undeclared** leg falls back to "the importing package's own resolution" — and which package that is depends entirely on where the `import()` is physically written, because Node ESM resolves a bare specifier against the module containing the call. #10943 turned that into an explicit parameter, `options.fallbackImport`, so a caller can hand in its own `import()`. `@objectstack/verify` (`bootStack`) and the `packages/qa/dogfood` enterprise probe both pass theirs; `serve`'s `importFromHost` did not, so it advertised the CLI's resolution and actually used `@objectstack/types`', which under a pnpm-isolated layout sees only `@objectstack/spec`. - -**Measured accept-set delta**: the undeclared fallback now reaches exactly what `packages/cli` itself declares, and nothing else. Re-measured with `import.meta.resolve` from a probe in each package — `chalk`, `@objectstack/plugin-auth` and `@objectstack/plugin-audit` resolve from `packages/cli` and not from `@objectstack/types`; every specifier `serve` itself routes through the helper (`@objectstack/service-cluster` and its drivers, `@objectstack/service-i18n`, `@objectstack/organizations`, `@objectstack/service-ai`, `@objectstack/service-ai-studio`) resolves from **neither**, which is why this was harmless in every shape that ships today. The #4719 declaration gate on the declared leg is untouched: a package that is merely reachable is still refused, and no app gains a way to load something it has not declared. - -**Two user-visible consequences.** A `plugins: [...]` entry naming a package the app does not declare but the CLI ships now resolves through the host importer rather than a separate local `import()` — same module, one attempt instead of two. And the undeclared-package diagnostic drops its "the caller did not pass `fallbackImport`" note, which `@objectstack/types` emits only for callers that withhold their base; the note existed so this gap would report itself, and it has now been closed rather than silenced. - -`Serve.importConfigPlugin`'s three-branch shape collapses to two in the same change. The undeclared branch kept a local `import()` *because* the helper's fallback resolved from the wrong package; with the base threaded, that branch and the re-entry branch are the same call, so the declaration is read once — by `readHostDeclaration` inside the helper — instead of being asked there and again here. Behaviour was measured case by case first: the app's declared copy still wins, a declared-but-uninstalled package still reports the install remedy, a package present-but-throwing still propagates as a crash (both paths gate on the one shared `isModuleNotFoundError`), and a package resolvable nowhere still produces the #4719 "declare it in that app's package.json" text. diff --git a/.changeset/cli-serve-port-drift-notice.md b/.changeset/cli-serve-port-drift-notice.md deleted file mode 100644 index b449b04b44..0000000000 --- a/.changeset/cli-serve-port-drift-notice.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os serve` announces a shifted port, naming the one you asked for and the one it took (#12543) - -In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` hops to -the next free port when the requested one is taken, so several example apps can -run side by side. That behaviour is unchanged and deliberate — production still -refuses to drift (#11113). What changed is that the hop is no longer silent. - -Previously the only trace of a shift was the ready banner printing the port that -was *bound*; nothing said it was not the port that was *asked for*, so every -reader had to already know the requested port and compare the two by hand. A -boot that shifts now prints, before anything else: - -``` - ⚠ Port 32869 is in use — serving on 32871 instead. - Development auto-shift: 32869 was not free, so this server took - the next one that was (32871). Anything still pointed at 32869 — a - proxy, an OAuth callback URL, another terminal, a test harness — is - talking to whatever holds 32869, not to this server. -``` - -The notice is written to **stderr**, like every other `os serve` diagnostic: -`stdout` carries JSON-RPC frames whenever the stdio MCP transport is mounted, so -nothing but protocol may go there. It prints only when the bound port actually -differs from the requested one — an ordinary boot on a free port is unchanged, -byte for byte. diff --git a/.changeset/cli-serve-port-input-validation.md b/.changeset/cli-serve-port-input-validation.md deleted file mode 100644 index e084137aba..0000000000 --- a/.changeset/cli-serve-port-input-validation.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os serve` refuses a port that cannot be a port, naming what the operator set (#12662) - -`--port` was a string flag whose only consumer was a bare `parseInt`. `os serve ---port abc` therefore became `NaN`, travelled the whole port policy untouched, -and reached the real `listen()` — which refused it at the socket layer: - -``` -ERR_SOCKET_BAD_PORT: options.port should be >= 0 and < 65536. Received type number (NaN). -``` - -An operator who mistyped a flag got back an error naming an internal option, -raised from a code path with no connection to the thing they typed. `--port -99999` parses fine and died in exactly the same place, and `PORT=abc` / -`OS_PORT=abc` are the same defect through a different door. - -The value is now checked before anything is done with it: - -``` - ✗ Invalid port: OS_PORT="abc" - A port must be a whole number from 0 to 65535 — 0 is legal, and - asks the kernel for any free port. Nothing was started, and no socket - was opened. - Correct OS_PORT in this process's environment (for example OS_PORT=3000), - or override it with --port 3000. -``` - -It names **which** input was used — `--port`, `PORT` or `OS_PORT` — because a -refusal that only said "invalid port" would repeat the defect one level up. The -range it states is interpolated from the bounds the code enforces, so the -sentence cannot drift from the check. `0` is accepted: `listen(0)` binds a -kernel-assigned port, so refusing it would have broken a working input in the -name of validating it, and the ceiling is 65535 — one less than the `< 65536` -the kernel's own message names. - -The check sits ahead of the port-conflict policy, so all three boot paths (the -development auto-shift, the production refusal, and a boot that enters neither) -are covered by one guard, and all three inputs are covered with it: `PORT` and -`OS_PORT` never reach flag parsing at all — they are read by the flag's -`default`, which oclif never runs a flag's parser over. - -**No value that boots today is refused.** `parseInt` remains the reader, so -every spelling it tolerates — `" 3000"` with the leading whitespace production -environments carry, `"3000.0"`, `"0x0BB8"`, `"+3000"` — still boots, on the same -port, byte for byte. Only the values that used to die at the socket are refused, -and now they are refused early, in the operator's own vocabulary. Written to -**stderr** like every other `os serve` diagnostic: `stdout` carries JSON-RPC -frames whenever the stdio MCP transport is mounted. diff --git a/.changeset/cli-serve-port-text-read-notice.md b/.changeset/cli-serve-port-text-read-notice.md deleted file mode 100644 index 15e72462c4..0000000000 --- a/.changeset/cli-serve-port-text-read-notice.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os serve` says so when a port is read as something other than what the text says (#12674) - -`os serve` reads its port with `parseInt`, and `parseInt` is tolerant in a way -that changes the *answer* rather than the spelling. `--port 3e3` binds port -**3**. `--port 0x0BB8` binds 3000. `--port 3000abc` binds 3000. The boot -succeeds, on a port the operator never named, and nothing anywhere says so — an -operator who wrote `PORT=3e3` meaning 3000 gets a server on port 3, and on a -non-root host that surfaces (much later, if at all) as an `EACCES` that still -does not name the coercion. - -The value is now announced when it does not read as the port it selected: - -``` - ⚠ PORT="3e3" was read as port 3. - That text is not a plain decimal number, and the reader that accepts it - is tolerant: it honours a leading 0x as hexadecimal and discards - everything from the first character that cannot continue the number. - Nothing downstream reads it again — 3 is the port this server asked - for, whatever the text looks like. - If that is not the port you meant, correct PORT in this process's - environment (for example PORT=3000), or override it with --port 3000. -``` - -**Nothing is refused, and nothing binds differently.** The accept set is exactly -what it was: every spelling that boots today still boots, on the same port, byte -for byte. Whether `os serve` should take only strict decimal text is a contract -question about a published CLI's accepted input, and it is deliberately left -open. This repairs the silence, which is where the harm actually was. - -The notice fires on a *difference*, so what counts as agreement is the whole of -it: leading and trailing whitespace, a leading `+` and leading zeros do not -change what the text says (`" 3000"`, `"+3000"` and `"08080"` are silent — the -first is what production `PORT` values look like, and a notice there would drone -at every ordinary boot). An exponent, a radix prefix, a fraction, a digit -separator or trailing text all do (`"3e3"`, `"1e10"`, `"0x0BB8"`, `"0b111"`, -`"3000.0"`, `"1_000"`, `"3000abc"` all speak). - -It names both the text and the port it selected — never a third number, because -`3e3` looks like 3000 to a reader but `3000abc` has no second reading and a -guess would be wrong the first time it met one. Written to **stderr** like every -other `os serve` diagnostic: `stdout` carries JSON-RPC frames whenever the stdio -MCP transport is mounted. diff --git a/.changeset/cli-truncation-remainder-notices.md b/.changeset/cli-truncation-remainder-notices.md deleted file mode 100644 index 6bd1be511b..0000000000 --- a/.changeset/cli-truncation-remainder-notices.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os build`, `os validate` and `os init` say how many diagnostics they withheld - -Nine more renders across the three authoring commands cut their list at a fixed -cap and printed nothing saying so — the `--strict-body` refusal list, the -author-time rule failures, the undeclared-authoring-key findings, the -access-matrix drift, the package-doc errors, and both halves of `os init`'s -scaffold self-test. The defect is the **silence**, not the cap: truncated -output that carries no notice is indistinguishable from complete output, so an -author who reads it and sees no further problems has read a list that stopped -early. Two of them even stated the true total in their own header and then -showed fewer rows, so the report gave two numbers that disagreed and explained -neither. - -On the gating lists it also undoes the thing `os validate` went out of its way -to provide. Its own comment records why every failing rule reports at once: -"the command used to exit at the first failing gate, so an author with three -unrelated problems fixed them in three round trips and could not see how deep -the hole went". Past the cap that is exactly what came back, one cap-width at a -time, with each round of fixes revealing a new batch that reads as fresh -breakage. - -Every cap stays. Over it the output now names the exact remainder: - -``` - ⚠ … and 30 more author-time rule failure(s) not shown (50 of 80) — re-run with --json for the full list -``` - -**The pointer is verified per site, and two notices deliberately omit it.** -`--json` publishes each of these lists at the very exit whose text face carries -the notice, so re-running really does return the complete set. `os init` -declares no `--json` flag at all, so both of its notices state the remainder -and name no remedy — a notice whose remedy does not work is worse than a silent -cut, because it sends the author down a path that returns the same truncated -view. - -At or under a cap, nothing new is printed and the rendering is byte-for-byte -what it was. diff --git a/.changeset/client-auth-set-initial-password.md b/.changeset/client-auth-set-initial-password.md deleted file mode 100644 index 275358633d..0000000000 --- a/.changeset/client-auth-set-initial-password.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/client": minor -"@objectstack/plugin-auth": patch ---- - -**SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. - -`AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout. - -The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one. - -**Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows. - -**Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed. diff --git a/.changeset/client-json-erasure-in-repo-families.md b/.changeset/client-json-erasure-in-repo-families.md deleted file mode 100644 index 66ae223315..0000000000 --- a/.changeset/client-json-erasure-in-repo-families.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): bind the five in-repo `return res.json()` methods, whose published type was `Promise< any >` (part of #12104) - -**Return-type narrowing on a published SDK (clause-②).** No runtime change: the -value each method resolves to is byte-identical before and after. Only the -DECLARED type moved, off `any` — which is exactly why a runtime test cannot -observe it and the pins for it are type-level. - -> ⓘ Angle brackets are spaced throughout (`Promise< any >`) on purpose — -> GitHub's body sanitizer strips tag-shaped spans, backticks and fenced code -> included. - -Each of the five carried no return annotation and ended `return res.json()`, so -its published type was `Promise< any >`, inherited from `lib.dom`'s -`Response.json(): Promise< any >`. The method text names neither `any` nor -`Promise` nor `unwrapResponse`, which is why the class was invisible to the -greps two earlier censuses used. - -## What each method declares now - -| method | declared before | declares now | why | -|---|---|---|---| -| `client.analytics.query` | `any` | `BaseResponse & { data: AnalyticsResult }` | dispatcher-served; `deps.success(v)` wraps and `res.json()` strips nothing | -| `client.analytics.meta` | `any` | `AnalyticsMetadataResponse` | same envelope; `data` is the bare `CubeMeta[]` projection | -| `client.analytics.explain` | `any` | `AnalyticsSqlResponse` | same envelope; `data` is `{ sql, params }` | -| `client.automation.trigger` | `any` | `BaseResponse & { data: AutomationResult }` | same envelope, over the payload its sibling `automation.execute` unwraps | -| `client.analytics.queryDataset` | `any` | `AnalyticsResult` | served by `@objectstack/rest`, which ends `res.json(result)` — no envelope | - -`any` is assignable to everything and admits every property read, so a -consumer's code can stop compiling where it previously did not. Concretely: - -- **Reading a payload key off one of the four ENVELOPED results.** - `(await client.analytics.query(q)).rows` compiled and was `undefined` at - runtime; the read the wire always required is `.data.rows`. Same for - `.data` on `meta` / `explain`, and `.data.runId` / `.data.screen` on - `automation.trigger`. -- **Reading `.data` off `queryDataset`**, which is served bare — likewise - `undefined` today, likewise refused now. -- Assigning any of the five results to an unrelated annotation, or forwarding - one to a differently-typed parameter. - -That break is the point: those call sites are already wrong at runtime and the -`any` is what hid it. The compiler is the channel that reaches every affected -consumer, and it is strictly more precise than a release note. - -## How the shapes were established - -By DRIVING the real producers — a real `AnalyticsService`, a real -`AutomationEngine`, the real `HttpDispatcher` and the real `RestServer`, with -only the socket stood in for — not by reading source and not by asserting -against a mock. Two spec response types that look like the right binding are -NARROWER than the contract their route relays -(`AnalyticsResultResponseSchema.data.fields` and -`TriggerFlowResponseSchema.data`), so those two annotations bind the producer's -contract instead; the near-miss is pinned so a later sweep cannot retarget them. - -## Scope - -The five families whose producers live in this repo. The 38 better-auth-backed -`auth.*` / `organizations.*` / `oauth.*` methods of the same class are untouched -and keep their erased `any` — they are exactly as permissive as before, and no -consumer loses anything by that. - -No ADR-0087 ledger entry: nothing here is a metadata surface. No Zod schema, no -`packages/spec` declaration and no stored representation changed — the erasure -lived only in a TypeScript return annotation — so `objectstack migrate meta` has -nothing to rewrite and an entry would have no artifact to project into. This is -the disposition #8140, #11925 and #12034 recorded for the same class of SDK -return-type narrowing. diff --git a/.changeset/client-meta-saveitem-query-options.md b/.changeset/client-meta-saveitem-query-options.md deleted file mode 100644 index 76e4d3b4b7..0000000000 --- a/.changeset/client-meta-saveitem-query-options.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/client': minor ---- - -`meta.saveItem` accepts the query-string options bag its route already reads — -`force`, `packageId`, `mode` — on both clients - -The Phase 3a-destructive gate refuses a metadata save with -`409 DESTRUCTIVE_CHANGE` and ends the message `— re-submit with ?force=true to -proceed.` Both REST `PUT` doors read `?force` off the query string and thread -it, so that sentence is true of an HTTP caller. It was **false of a -first-party SDK caller**: `meta.saveItem(type, name, item)` built a bare path -and a body and sent no query string at all, on either declaration. A caller -who did literally what the refusal prescribed got the identical refusal back, -and the only way to act on it was to abandon `@objectstack/client` for raw -`fetch`. - -Three parameters are newly reachable, and they are exactly the three -`PUT /api/v1/meta/:type/:name` reads: - -- **`force?: boolean`** — `?force=true`, the destructive-change opt-in the 409 - message names. Only the opt-IN is spelled on the wire: `false` and - `undefined` both omit the parameter rather than sending `?force=false`. - That is a hazard avoided, not tidiness — the door refuses a *repeated* - `force` because a repeated value arrives as an array and a non-empty array - is truthy, so an opt-OUT that reached the wire twice would switch the guard - ON. -- **`packageId?: string`** — `?package=`, binding the saved row to a - software package (`sys_metadata.package_id`). Named `packageId` to match the - sibling `getItem` / `getItems` options on the same object. -- **`mode?: 'draft' | 'publish'`** — `?mode=draft`, staging the write as a - pending draft. `'publish'` is the default said out loud and deliberately - sends nothing, since the door acts on `mode=draft` alone. - -**Backward compatible.** The bag is optional and an options-less call builds a -byte-identical URL to before — `''`, not a trailing `?`. Existing -three-argument callers are unaffected, and pins measure that rather than -assuming it. - -Both declarations move together — the unscoped `ObjectStackClient.meta` and -the environment-scoped `ScopedProjectClient.meta` — sharing ONE exported -`SaveMetaItemOptions` type and ONE query builder rather than a literal copied -into each. They are the same method on two clients reaching one pair of routes -(the scoped mount is the same route registration replayed under -`/environments/:environmentId`, so it reads the same three parameters), and -every divergence measured between these twins so far has been closed as a -defect. A bag spelled twice is the next one waiting to be introduced. - -The branch was selected by measurement, not preference: the SDK is the real -metadata-write path for both surfaces the ruling named. The CLI's `os meta -register` goes through `client.meta.saveItem` and the CLI has no raw-HTTP -metadata-save path at all; Studio reaches it from 21 production call sites -across `@object-ui/app-shell`, `plugin-designer`, `data-objectstack` and the -console app — including the object and field designers, where dropping a field -and saving is precisely what raises the destructive 409. diff --git a/.changeset/client-packages-write-verbs-bare-row.md b/.changeset/client-packages-write-verbs-bare-row.md deleted file mode 100644 index 2e31d6d96c..0000000000 --- a/.changeset/client-packages-write-verbs-bare-row.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `packages.install` / `enable` / `disable` declare the bare `InstalledPackage` row the only serving surface actually sends (#12034) - -**Accept-set narrowing on a published SDK (clause-②), and a false declaration -deleted.** No runtime change: the value each method resolves to is -byte-identical before and after. What moved is the DECLARED type — and unlike -its #11925 siblings this one was not merely erased, it was **wrong**. - -FROM → TO, all three methods: - -| method | declared before | declares now | -|---|---|---| -| `client.packages.install(manifest, opts?)` | `{ package: any; message?: string }` | `InstalledPackage` | -| `client.packages.enable(id)` | `{ package: any; message?: string }` | `InstalledPackage` | -| `client.packages.disable(id)` | `{ package: any; message?: string }` | `InstalledPackage` | - -No surface has ever emitted `{ package, message }` for these three. Each is -served by exactly one implementation — `runtime`'s `/packages` dispatcher domain -— and it answers `success(pkg)`, i.e. `{ success: true, data: }`, which -`unwrapResponse` strips to the bare row. `@objectstack/rest`'s registrar mounts -no twin for any of them (it mounts only `POST /packages/publish`, -`GET /packages`, `GET /packages/:id`, `DELETE /packages/:id`), so there was -never a question of which surface to match. - -**Migration — read the row, not `.package`.** Because the member was `any`, -the false read compiled and silently produced `undefined` at runtime: - -```ts -// BEFORE — compiled, and `pkg` was `undefined` at runtime -const pkg = (await client.packages.enable(id)).package; -const note = (await client.packages.install(manifest)).message; - -// AFTER — the response IS the row -const pkg = await client.packages.enable(id); -pkg.enabled; // the state the verb just changed -pkg.manifest.version; -``` - -A consumer stops compiling where it reads `.package` or `.message` off these -three results, or assigns the result somewhere `InstalledPackage` does not fit. -That break is the point: those call sites are already broken at runtime today -and the `any` is what hid it. The compiler is the channel that reaches every -affected consumer, and it is strictly more precise than a release note. - -**What deliberately did NOT change: `client.packages.get`.** It keeps -`{ package: any }`. That route is a real fork — the dispatcher answers the bare -row while the REST registrar answers `{ package: { ...row, source } }`, both -measured by driving each registrar — so no declaration is true on both surfaces. -Binding either member would harden a falsehood, which is the defect this change -removes for its neighbours. Making `get` bindable requires converging the two -PRODUCERS, a wire-behaviour change to two mounted surfaces; the measured -convergence cost is recorded on #12034 for that ruling. - -No ADR-0087 ledger entry: nothing here is a metadata surface. No Zod schema, no -`packages/spec` declaration and no stored representation changed — the phantom -members existed only in a TypeScript return annotation — so `objectstack migrate -meta` has nothing to rewrite. This is the disposition #11925 and #8140 recorded -for the same class of SDK return-type narrowing. diff --git a/.changeset/client-precise-sdk-return-types.md b/.changeset/client-precise-sdk-return-types.md deleted file mode 100644 index 05e1a4990b..0000000000 --- a/.changeset/client-precise-sdk-return-types.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/client': minor ---- - -Bind the SDK's erased return types to the `@objectstack/spec` contracts the package already depends on - -**This is a NARROWING of published return types.** 41 methods that resolved to `any` (or to an -envelope carrying an `any[]`) now resolve to the contract type the route actually answers, and 12 -fixed-shape `automation.*` methods gain a constrained generic in place of ``. Nothing -changes at runtime — no request, response, unwrapping or error path is touched — but code that -compiles today against these methods can stop compiling. `any` is assignable to everything and -admits every property read, so the previous declaration accepted assignments, property reads and -parameter forwarding that a precise type refuses. - -What a consumer could stop compiling against, per family: - -- **`automation`** — `get`/`getFlow` are `FlowParsed`; `runs.get`/`getRun` are `ExecutionLog`; - `runs.list`/`listRuns` are `{ runs: ExecutionLog[]; hasMore: boolean }`; `execute` and `resume` - are `AutomationResult`; `getScreen` is `{ runId: string; screen: ScreenSpec }`; `listActions` and - `listConnectors` carry `ActionDescriptor[]` / `ConnectorDescriptor[]` instead of `any[]`. ⚠️ The - biggest practical break is `AutomationResult.screen`, `.runId`, `.status` and `.summary` being - **optional**: a completed run carries no screen, so `result.screen.nodeId` must become - `result.screen?.nodeId`. The six flat aliases and their `ScopedProjectClient` mirrors move - together. ⚠️ `` became `` on those twelve: an explicit type argument - still works when it narrows the platform shape (`getFlow`), - but one naming an unrelated type is now refused — including where TypeScript used to infer it - from the assignment's own annotation. -- **`approvals`** — `recall` / `revise` / `resubmit` are `ApprovalRecallResult` / - `ApprovalSendBackResult` / `ApprovalResubmitResult`; `remind` is - `{ request: ApprovalRequestRow; notified: number }`; `requestInfo` and `comment` are - `{ request: ApprovalRequestRow }`. These join `reassign` and `listActions`, which were already - typed this way beside them. -- **`shares` / `shareLinks`** — `shares.list` is `RecordShare[]`, `shares.grant` is `RecordShare`; - `shares.rules.list` / `save` / `get` are `SharingRuleRow`(`[]`) and `rules.evaluate` is - `SharingRuleEvaluationResult`; `shareLinks.create` / `list` are `ShareLink`(`[]`). -- **`reports`** — `list` / `save` / `get` are `SavedReport`(`[]`), `run` is `ReportRunResult`, - `schedule` / `listSchedules` are `ReportSchedule`(`[]`). -- **`security`** — `describeDelegableScope` is `DelegableScope`; `explain` is `ExplainDecision` (the - `z.input` form `ISecurityService.explain` declares and the route relays verbatim — **not** the - post-parse `ExplainDecisionParsed`, since no parse runs on that path); the three - `suggestedBindings` methods carry their `{ suggestion, … }` / `{ suggestions, synced }` envelopes. - The suggestion ROW stays `Record` by contract, but `bindingCreated` and - `synced.{created,confirmedObserved,pruned}` stop being erased. -- **`email` / `datasources.external`** — `email.send` is `SendEmailResult` (branch on `status`). - ⚠️ The four federation methods are **envelope-wrapped** and the obvious binding is the wrong one: - `listTables` answers `{ tables: RemoteTable[] }`, not `RemoteTable[]`; likewise - `{ draft: ObjectDraft }`, `{ object: ImportObjectResult }`, `{ catalog: ExternalCatalog }`. - `validate` is a bare `SchemaValidationReport`. -- **`ScopedProjectClient.packages.list`** — `{ packages: InstalledPackage[]; total: number }`. - -Four methods deliberately keep `Promise` and say so in their docblocks: `automation.create` / -`automation.update` echo an unvalidated request body, and `search` / `data.clone` answer shapes -declared inline in the implementation rather than in `@objectstack/spec`. Those are missing -*contracts*, not missing annotations, and authoring them belongs to the spec package. The -caller-supplied generics on `data.*` and `actions.*` are unchanged — there the payload really is -the caller's. diff --git a/.changeset/client-saveitem-ifmatch-header.md b/.changeset/client-saveitem-ifmatch-header.md deleted file mode 100644 index f79835ad06..0000000000 --- a/.changeset/client-saveitem-ifmatch-header.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `meta.saveItem` can send the `If-Match` OCC header it already told callers to send (#11713) - -`saveItem`'s own docstring has always named the ADR-0008 optimistic-concurrency -protocol: the resolved `version` is the token, echo it back as the `If-Match` -request header on the next write to the same item, and a concurrent edit is -reported as `409 METADATA_CONFLICT` instead of silently overwriting. Both REST -`PUT` doors read `if-match` and thread it as `parentVersion`, so that sentence -was true of a raw-HTTP caller. It was **false** of a first-party SDK caller: -neither `saveItem` declaration accepted a header, an `ifMatch`, or anything -that became one — so an SDK caller who did exactly what the docstring said had -nowhere to put the token and their concurrent edit overwrote anyway, answered -`200`. Declared, not enforced, with no signal at the call site. - -**What is new:** `ifMatch?: string` joins the `SaveMetaItemOptions` bag that -`#11391` landed, on **both** `saveItem` declarations — the unscoped -`ObjectStackClient.meta` and the environment-scoped -`ScopedProjectClient.meta` — wired to the `If-Match` request header through a -single shared builder, the same way the three query parameters go through one -shared query builder. The twins cannot drift. - -```ts -const saved = await client.meta.saveItem('object', 'customer', doc); -// …later, guarded against a concurrent edit: -await client.meta.saveItem('object', 'customer', next, { ifMatch: saved.version }); -// a stale token now answers 409 METADATA_CONFLICT instead of overwriting -``` - -Purely additive and opt-in. Only a non-empty token reaches the wire: -`undefined` and `''` both omit the header entirely — the `init` handed to -`fetch` carries no `headers` key at all — so every existing call is -byte-identical and last-write-wins remains the default, on the wire and on the -door. Unlike the bag's `mode`, `ifMatch` reaches **both** save doors: the -compound-name twin `PUT /meta/:type/:section/:name` reads `if-match` and strips -ETag-style quotes exactly as the single-segment door does. - -Aligned deliberately with the other first-party client: the same member name, -the same header, and the same truthy guard as `MetadataClient.save` in -`@object-ui/data-objectstack` — two first-party clients, one behaviour. diff --git a/.changeset/client-sdk-example-typecheck.md b/.changeset/client-sdk-example-typecheck.md deleted file mode 100644 index e50e8be6f3..0000000000 --- a/.changeset/client-sdk-example-typecheck.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/client-react": patch -"@objectstack/client": patch -"@objectstack/spec": patch ---- - -fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) - -TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by -building and reading the emitted declarations), so they reach consumers directly in their -editor's hover tooltip — copying one is the intended usage. Two were actively wrong: - -- `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or - `null`), which has no `.map` — copied verbatim, this throws once the query resolves. - Fixed to `data?.records.map(...)`. -- `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not - exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real - `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. - -While in there: every other `@example` on this surface (18 more, across -`client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` -and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each -previously omitted the `import` for the hook or type it demonstrated, and three -`realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose -placeholder, a syntax error once copied. - -**`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now -type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in -this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish -`files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface -moves. Named here only because the fixed-version group requires every package with a -source diff to be covered by a changeset; the actual version bump is a byproduct of the -group moving together, not a claim that spec shipped something new. - -No exported type, function signature, or runtime behaviour changed on any of the three — patch. diff --git a/.changeset/client-unannotated-return-erasure.md b/.changeset/client-unannotated-return-erasure.md deleted file mode 100644 index 78e1627a0c..0000000000 --- a/.changeset/client-unannotated-return-erasure.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): bind the three verifiable methods of the unannotated return-type erasure population to their spec contracts (#11925) - -**Return-type narrowing on a published SDK.** No runtime change — the value each -method resolves to is byte-identical before and after. Only the DECLARED type -moved, off `any`, which is precisely why a runtime test cannot observe it and -the pins for it are type-level. - -`any` is assignable to everything and admits every property read, so for each -method below a consumer's code could stop compiling where it previously did -not: assigning the result to an unrelated annotation, reading a property the -bound type does not declare, or forwarding the value to a differently-typed -parameter. - -## What changed, per family - -**`client.packages.list` → `{ packages: InstalledPackage[]; total: number }`** -(was `{ packages: any[]; total: number }`). A consumer stops compiling if it -reads any key off a row that `InstalledPackage` does not declare. Note in -particular `source` (`'database' | 'registry' | 'both'`): the REST surface -spreads it onto each row, the dispatcher surface does not, and it is therefore -deliberately NOT declared — code reading `pkg.source` off this result compiles -today and will not after. `total` and the array envelope are unchanged. - -**`client.packages.update` → `InstalledPackage`** (was `any`). This method -declared no envelope before, so nothing about the shape claim changed; a -consumer stops compiling if it reads an undeclared key off the returned row, or -assigns the result somewhere `InstalledPackage` does not fit. - -**`ScopedProjectClient.packages.get` → `{ package: InstalledPackage }`** (was -`{ package: any }`). The `{ package }` envelope is unchanged; only the member -narrowed. A consumer stops compiling if it reads a key off `.package` that -`InstalledPackage` does not declare — again including `source`, which this -route does send and which stays undeclared for consistency with its already -bound `list` sibling. - -## What deliberately did NOT change - -The other 36 methods in the measured population keep their erased `any`, each -with a docblock stating why and pointing at the issue that carries it: -`meta.*` history/diagnostics (9) and eight `packages.*` routes have no published -response contract to bind to (#12038); `client.packages.get` has two mounted -surfaces that emit different envelopes and `install`/`enable`/`disable` declare -an envelope no surface emits (#12034); the 15 cloud `projects.*` methods call a -control plane that speaks snake_case while the `@objectstack/spec/cloud` rows are -camelCase, so binding to them would compile and be false (#12036). - -No consumer loses anything by those staying `any` — they are exactly as -permissive as before. diff --git a/.changeset/clone-permission-set-carries-all-facets.md b/.changeset/clone-permission-set-carries-all-facets.md deleted file mode 100644 index e8d37e3b2f..0000000000 --- a/.changeset/clone-permission-set-carries-all-facets.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Make `clone_permission_set` carry the system permissions, row-level security -and tab permissions it was silently dropping - -The Clone action POSTs its `params` values to `/api/v1/data/sys_permission_set`, -so the params list *is* the payload. It named two of the six definition facets a -`sys_permission_set` row carries — `object_permissions` and `field_permissions` -— leaving `system_permissions`, `row_level_security` and `tab_permissions` -absent from the body. `permissionSetBodyFromRow()` then read each one through -`parseMaybeJson(undefined, …)` and filled the empty default, so cloning a set -that grants `setup.access`, or one carrying row-level security policies, -produced a clone with none of them: record created, success toast fired, and the -missing half discoverable only by diffing the two records. - -The three now travel, in the same JSON-string shape the two listed columns -already used. Nothing about what the door ACCEPTS changed — `permissionSetBodyFromRow()` -already read all six columns; what changed is what the action SENDS. - -This became urgent one commit ago. The save door now refuses an in-place edit of -a package-declared permission set **and its refusal message tells the admin to -clone**, which made this action the platform's own recommended remedy while it -was still dropping three facets — an admin following that instruction lost -grants quietly. The failure direction was fail-closed (fewer grants), which is -why it was quiet. - -`admin_scope` is **deliberately not copied** (maintainer ruling 2026-08-24). -Putting an ADR-0090 D12 delegated-admin authority onto a brand-new -organization-owned set on the admin's behalf is a privilege decision, not a -field copy. The Clone dialog now says so in its description, so the omission -reads to the admin as a decision rather than as the same silent drop — grant a -scope deliberately on the new set if it needs one. - -Pinned by `packaged-permission-set-lock.test.ts` pin 6, which assembles the -clone payload by READING the action's params list rather than restating it, and -asserts each facet by identity against a non-empty value — the empty default -(`[]` / `{}`) is exactly what a "present" assertion would have accepted. Its -control proves the exclusion is live: the base fixture carries a real -`admin_scope`, and the clone still has none. diff --git a/.changeset/cluster-false-authz-posture-declaration.md b/.changeset/cluster-false-authz-posture-declaration.md deleted file mode 100644 index 74aadb439c..0000000000 --- a/.changeset/cluster-false-authz-posture-declaration.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -docs(runtime): `cluster: false` does not mean `service-cluster` contributes nothing — say so where it is read (#12679) - -No behaviour change. This ships a **statement**, at the three places an -engineer or an operator forms their belief about what the `cluster` flag turns -off, plus the pin that keeps the statement true. - -Since the authorization-cache substrate (#11968), `Runtime` registers -`AuthzClusterBridgePlugin` **unconditionally** — `cluster: false` included — so -that flag stopped meaning "this package contributes nothing" without anything -saying so. That is the declared-not-equal-actual shape: someone reading -`cluster: false` in a config reasonably concludes `@objectstack/service-cluster` -does not participate, and is wrong. #12679 weighed moving the posture statement -out to `core` and **ruled against it** (option A): the registration is correct -where it is, because an authorization grants cache running with no invalidation -bus at all is the *loudest* case the posture check has, and skipping the check -under `cluster: false` would silence the platform precisely in the configuration -most worth announcing. The cost of keeping it is a flag whose name overstates -what it turns off, and the ruling pays that cost in text rather than in a -cross-package move. - -- **`RuntimeConfig.cluster`'s docblock** now states what `false` actually skips - (the two *cluster* plugins) and names the one plugin that survives it, why it - survives, and that it stays inert on the shipped default — with - `OS_AUTHZ_GRANTS_CACHE_TTL_MS` at `0` it attaches nothing, publishes nothing - and logs nothing above `debug`. This text ships in the package's `.d.ts`, so - it reaches an integrator in their editor at the moment they write the flag. -- **`content/docs/kernel/cluster.mdx` §8** carries the same statement - author-facing, replacing "skip registration entirely" — which was accurate - before #11968 and is not any more. -- **The `cluster: false` pin** asserts the bridge **by name, before any count**. - It already named the bridge, but a `toHaveBeenCalledTimes(1)` ran first and - aborted the test, so dropping the registration reported `expected 1, received - 0` — arithmetic about a security-relevant statement. It now fails with the - plugin id and a message explaining the contract; the exact-set and count - assertions are kept, moved after it. - -Operators changing nothing see nothing change. The one observable difference -predates this changeset: under `cluster: false` the plugin *count* is 1 rather -than 0, which matters only if you assert on it. diff --git a/.changeset/component-node-visibility-guidance-post-5505.md b/.changeset/component-node-visibility-guidance-post-5505.md deleted file mode 100644 index ba37e77c74..0000000000 --- a/.changeset/component-node-visibility-guidance-post-5505.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `COMPONENT_NODE_VISIBILITY_GUIDANCE` no longer claims a hoisted `properties` visibility key is evaluated by nothing (#11033) - -The `COMPONENT_NODE_VISIBILITY_KEYS` key-set guard's `prescription` — the text -emitted to an author when a visibility key (`visible` / `visibleWhen` / …) is -written inside `properties` instead of on the component node — closed with: - -> Inside `properties` it is hoisted onto the node by the renderer but evaluated -> by nothing — the component renders unconditionally, which is a visibility -> gate that silently does not gate. - -That was true when it was written and is false since objectui#5505 -(`c86185eb5`, merged 2026-08-21): `SchemaRenderer`'s node-level `visibleWhen` -evaluator now binds `record`, so the hoisted value IS evaluated by the -node-level gate. Post-#5505 the props-level and node-level forms evaluate the -same value over the same `RecordContext` and compose as an idempotent AND — -there is no gate that silently fails to gate. - -The prescription now states that truth instead, and keeps its move-it-up -advice resting on the reason that still holds: `visibleWhen` at the node, -beside `type` and `id`, is the ADR-0089 canonical spelling — a layer-discipline -argument, not an inertness one. - -Message text only. No accept/reject verdict changes, no schema shape changes, -and no runtime behaviour changes — both gates already evaluated the value -identically before and after this change. diff --git a/.changeset/compound-meta-door-mode-draft.md b/.changeset/compound-meta-door-mode-draft.md deleted file mode 100644 index 511d300334..0000000000 --- a/.changeset/compound-meta-door-mode-draft.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): `?mode=draft` now stages on the compound-name metadata write door - -`PUT /api/v1/meta/:type/:section/:name` — the compound-name door, the one you -reach with a name like `views/all_leads` — built its `saveMetaItem` request -field by field and `mode` was not one of the fields. Its single-segment twin -`PUT /api/v1/meta/:type/:name` has read that parameter all along. The parameter -was never refused here, only dropped, so the request was answered `200` and -published **live**. Both doors now read it. - -**Two behaviour changes, and both can be observed by an unchanged caller.** - -**1. `?mode=draft` on this door changes OUTCOME, not acceptance.** The request -was accepted before and is accepted now; what moved is what it does. - -| Request | Before | After | -| --- | --- | --- | -| `PUT /meta/object/crm/task?mode=draft` | `200`, `"state":"active"` — the live row overwritten, nothing staged | `200`, `"state":"draft"` — a staged row written, the live row untouched | - -If you send `?mode=draft` to a compound name today and rely on the write going -live — for instance because you never call `POST /meta/:type/:name/publish` — -those writes stop taking effect immediately and start waiting for a promotion. -Drop the parameter to keep publishing straight away. `mode=publish`, an -unrecognised `mode=`, an empty `mode=` and no `mode` at all are all unchanged: -they publish, exactly as before. The spelling test is the twin's, `draft` -case-insensitive. - -⚠️ **The draft you can now stage has no per-item REST promotion door in this -arity.** `POST /meta/:type/:name/publish` is mounted for single-segment names -only, while its read twin `GET /meta/:type/:section/:name/published` is mounted -for both — so a compound-named draft is writable and readable over REST and not -promotable there. Until that route exists, promote through -`POST /packages/:id/publish-drafts` (whole-package) or the runtime dispatcher's -own `meta.publish` verb. Tracked in #11932; this release does not change it. - -**2. A repeated `?mode` is now REFUSED where it was accepted.** This narrows -what the door takes. `?mode=draft&mode=draft` arrives as an array; the -`typeof === 'string'` test is false for it, so before this change it fell back -to publishing live under a `200`. It is now answered `400` -`{ "error": { "code": "VALIDATION_ERROR" } }` and nothing is written — the -#6877 guard this door already applied to `force` and `package`, extended to the -parameter it just gained, and the same answer the single-segment twin has given -for a repeated `mode` since #6877. A single occurrence encoded as an array -(`?mode=draft` once) is still accepted; the guard unwraps rather than -blanket-refusing. - -Nothing else on the door moved: `?force`, `?package`, the `meta-envelope` -write face, the `manage_metadata` gate and the `501` envelope are untouched, -and the single-segment twin is untouched. diff --git a/.changeset/console-9602dc820450.md b/.changeset/console-9602dc820450.md deleted file mode 100644 index 4c23a46aa8..0000000000 --- a/.changeset/console-9602dc820450.md +++ /dev/null @@ -1,200 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `9602dc820450`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 188 releasing of 279 changesets added across 359 non-merge commits; omitted: 91 release-nothing changesets, 82 commits carrying no changeset (they ship no package code). - -- **minor** — Setup › **Packaged automation** gains a packaged **actions** section beside its flows one — the surface half of ADR-0126 §8 item 2 (objectui#6412; the engine, ledger and both disp… (objectui `9602dc820`) -- **minor** — Setup gains a **Packaged automation** page — the operational surface for the flows an installed package ships (ADR-0126 §7.4, objectui#6301). Reached the way every other framework… (objectui `b362c1b47`) -- **minor** — `TableColumn.type` now has ONE canonical value set across all three ends that disagreed (objectui#5853, maintainer ruling 2026-08-25, Option B: the 8-literal interface union is ca… (objectui `fc62bb490`) -- **minor** — `TimelineSchema` now declares the presentational keys the timeline renderer actually reads (objectui#6170, maintainer ruling 2026-08-25 — the same family rule adopted on objectui#… (objectui `e4559d1e0`) -- **minor** — `navigateOnSuccess` is relative-only, escapes the interpolated id, and is deprecated in favour of `submitBehavior` (objectui `636b2363f`) -- **minor** — `objectui check` recognises a schema by validating it, and reports broken ObjectUI files instead of filing them as foreign ones. (objectui `7dc08a3e7`) -- **minor** — The Field Designer no longer offers a formula-expression textarea, and no designer write path emits a `formula` key (objectui#6043). (objectui `864154e77`) -- **minor** — `ComponentMeta` at the registry is now DERIVED from the one declaration in `@object-ui/types` instead of restating it, and `tags` / `description` reach the registration surface (o… (objectui `44d075ba0`) -- **minor** — `StackSchema` now SHIPS the members it declares (objectui#6151). Its emitted declaration carried one property — `type` — where it was meant to carry twenty-five. (objectui `db3a6008e`) -- **minor** — ⚠️ **Behaviour change: `current_user` predicates that have been doing nothing on the console form routes and in the wizard's submit gate now TAKE EFFECT.** Read this before upgrad… (objectui `5173a5ece`) -- **minor** — **BREAKING** — Object-level metadata payloads no longer emit the three keys `ObjectSchema` refuses by name — **group**, **sortOrder** and **relationships** (objectui#6223). (objectui `d18a0d3af`) -- **minor** — `radio-group` now renders the `orientation` its own type has always declared (objectui#6158). (objectui `6fd2cf7a0`) -- **minor** — ⚠️ **Behaviour change: an authored `FormSection.visibleWhen` that has been doing nothing will now START HIDING SECTIONS.** Read this before upgrading if any of your metadata autho… (objectui `971d38748`) -- **minor** — **Breaking (shipped as `minor` per AGENTS.md §版本号策略).** The published `toSortItems` export now reads its sort direction from **`order`** only. The retired spelling is **`direction… (objectui `c5fbe0b99`) -- **minor** — **View configuration is explicitly org-wide, and its write path is now gated (objectstack#7494's ruling, maintainer 2026-08-12).** The `sort` / `hiddenFields` / `columnState` / `r… (objectui `41b7ce3ce`) -- **minor** — Retire `CRUDSchema` and the `type: 'crud'` node spelling (objectui#5373, maintainer ruling 2026-08-20, route 2) under ADR-0049 enforce-or-remove. (objectui `105f3c55c`) -- **minor** — The metadata designer states its package on the publish step, not only on the save (#5420) (objectui `b470e9150`) -- **minor** — Grid headers offer a sort click only on columns the PLATFORM says it will order by (objectui#5729 — the consumer leg of objectstack#10235, maintainer ruling A, 2026-08-23: the pla… (objectui `490d9a93a`) -- **minor** — **Retired the designer-surface dashboard `aria` pair — `DashboardConfig.aria` and `DashboardConfigSchema.aria`** (objectui#5852). (objectui `e7957ab87`) -- **minor** — The metadata designer's View **column list** now reads a column's identity — row label and bound field name — in the ObjectStack canonical spelling only: `field` and `label`. The… (objectui `f471b4f73`) -- **minor** — An auto-derived related list now orders its rows by the CHILD object's default list view `sort`, instead of falling to the server's primary-key order (objectui#5795). A task versi… (objectui `6d63cd029`) -- **minor** — **BREAKING** — `ObjectTrigger` and `ObjectRelationship` are removed from `@object-ui/types` — two hand-written interfaces orphaned by the `ObjectSchemaMetadata` derivation (objectui#5859, triage… (objectui `41df89320`) -- **minor** — `ComponentMeta` is now declared once and re-exported, and `PluginComponentMeta` is deprecated in favour of `ComponentMeta` (objectui#5893). (objectui `6d1c15536`) -- **minor** — `MetadataClient.layered()` validates the ADR-0010 protection envelope against the producer's own schema at the boundary, instead of casting ten wire fields through unchecked (obje… (objectui `94e2fa725`) -- **minor** — `ObjectGanttSchema` declares the ten gantt keys `ObjectGantt` actually reads (objectui#5903, triage 2026-08-24). Every one is a real, working, documented feature — `readOnly`, `mo… (objectui `00f3eb5b9`) -- **minor** — Route a `data-table`'s `emptyAction` node through `SchemaRenderer`, so a `visibleWhen` authored on it is actually evaluated (objectui#5926 gap 1). (objectui `f8cdbf2cc`) -- **minor** — **Behaviour change.** Form section and field `visibleWhen` predicates that silently failed OPEN now actually evaluate — and a rule that resolves FALSE now hides the field (objectu… (objectui `aefe42898`) -- **minor** — **`@object-ui/types/zod` now accepts seven spellings its own TypeScript declarations already declared** (objectui `69a2163d9`) -- **minor** — **Breaking (shipped as `minor` per AGENTS.md §版本号策略).** `ObjectViewProps.views[].sort` now spells its direction key **`order`**. The retired spelling is **`direction`** — named he… (objectui `53dc89db8`) -- **minor** — **BREAKING (authoring): `ui:icon` names its glyph with `icon`, not `name`** (objectui `d2fb6efb4`) -- **minor** — Close the dashboard widget `type` vocabulary, and admit `metric-card` as objectui's own component extension. (objectui `f7e34ca0b`) -- **minor** — Console telemetry can now be hard-disabled on an already-built artifact (objectui `26fbe09b0`) -- **minor** — **BREAKING** — Retire the `ActionCondition` `{ expression, then, else }` branch shape from `ActionSchema.condition` (objectui#3917, maintainer ruling 2026-08-09 route B, enforce-or-remove). (objectui `6a9158602`) -- **minor** — `ui:icon`: an unresolvable glyph now renders a visible placeholder instead of nothing (objectui `7cd398737`) -- **minor** — Classify `/analytics/query` failures by their ADR-0112 `code` rather than their HTTP status, so a chart is no longer answered from a different code path behind the wrong explanati… (objectui `b2065e711`) -- **minor** — **BREAKING** — Retire the `MobileOverrides` type and its `mobileOverrides` mount point (objectui#4919, maintainer ruling 2026-08-19, ADR-0049 enforce-or-remove). (objectui `2d7304d8a`) -- **minor** — Console half of `ActionSchema.onSuccess` post-success navigation. (objectui `053fdc8f9`) -- **minor** — `ObjectSchemaMetadata` is now derived from `@objectstack/spec`'s `ServiceObject` instead of being a hand-written copy (objectui#5362; maintainer ruling 2026-08-20: the object docu… (objectui `b2ea29717`) -- **minor** — **Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the `aria` member (`{ ariaLabel?, ariaDescribedBy?, role? }`). Its doc comment claimed alignment w… (objectui `18a8e7db7`) -- **minor** — `record:path` stops looking like a control it cannot be. (objectui `0b12a3315`) -- **minor** — **Behaviour change:** the spec's view-level `map` block on a list view is now read at runtime. `ListMapConfigSchema` (objectstack#9340) has been authorable and validated since the… (objectui `e2e8e68f1`) -- **minor** — The console form now renders a refusal message the producer explicitly marked as user-facing, instead of always substituting a generic string (objectui#5210). (objectui `ae61ad4a2`) -- **minor** — The spec-bridge's form-view input now accepts everything `@objectstack/spec`'s FormViewSchema accepts — three key types had drifted narrower, and one of them had inverted (objectu… (objectui `cac64b3f1`) -- **minor** — The console record page and the `record:activity` block read ONE `sys_activity` type table, so a scheduled meeting no longer appears on one and vanishes on the other. (objectui `d06fba8d8`) -- **minor** — The Studio copilot tells the agent WHAT the user is discussing (cloud#1610 send half): `ChatPane` accepts a `surfaceContext` and sends it as `context.surface` on every turn (the t… (objectui `eddc1dd97`) -- **minor** — 设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive… (objectui `1939c9610`) -- **minor** — The built-moment transition (#5799): a build conversation that has produced a WHOLE-APP build auto-routes to `/studio//interfaces` — on live completion and on reopening the c… (objectui `6bd343d29`) -- **minor** — Dashboard flat `table` widget: render the server's grand total as a `tfoot` row (objectui#5846). (objectui `9e725e06a`) -- **minor** — Dashboard `table` widget — a numeric **measure** column now sorts **descending on the first click** (objectui#5845). (objectui `1d9100eeb`) -- **minor** — Studio simplification, first cut (#5813): the Data pillar's object sub-tabs collapse to 记录/表单 with the five power panels (验证/钩子/操作/API/设置) behind one 「高级」 menu (the open panel's n… (objectui `19479a37d`) -- **minor** — One pending-drafts source of truth (#5801): `usePendingDrafts` / `fetchPendingDrafts` replace the five hand-rolled `GET /api/v1/meta/_drafts` copies behind the home banner, Studio… (objectui `469723471`) -- **minor** — Dashboard `table` widget (dataset-backed, flat grouped table) — four rendering fixes so it reads as a table rather than a raw dump (objectui#5827): (objectui `62c0fa164`) -- **minor** — **The page-editor palette now offers the canonical `record:discussion` instead of the `record:chatter` alias.** (objectui `8b1a8f38c`) -- **minor** — `dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshInterval` — so `va… (objectui `2e471a64c`) -- **minor** — **`viewMode` is now declared authoring surface on `ObjectGanttSchema`, and both gantt renderer branches honour it** (objectui#5074, maintainer ruling 2026-08-19: declare-and-wire;… (objectui `199d31bb9`) -- **minor** — Retire `ThemeSwitcherSchema` (`type: 'theme-switcher'`) and `ThemePreviewSchema` (`type: 'theme-preview'`) — the two remaining theme component kinds, which no renderer implemented… (objectui `b7543a9c9`) -- **minor** — **BREAKING** — Split the static `table` column type off the rich shared `TableColumn` (objectui#5474, maintainer ruling 2026-08-22: Option C), so declared = enforced holds per renderer. (objectui `3ccd9e8cb`) -- **minor** — Remove the inert `showRefresh` designer input from the `object-view` registration (objectui#5567). (objectui `4ef29f011`) -- **minor** — `'agenda'` leaves `defaultView` on all three of its declaration faces (objectui#5784 — the `defaultView` sibling of objectui#5740's retirement on `CalendarViewSchema.view`; ADR-00… (objectui `ed8df3e50`) -- **minor** — The 确认修改 (confirm changes) card now carries a UI-owned terminal state after approval (#5695): `detectReplayOutcome` lifts the confirm-replay envelope (`replay_*` tool results) int… (objectui `6ce89daae`) -- **minor** — `validateTree` now reports a dashboard widget `options` key that no renderer consumes as a `unconsumed-widget-option` **warning** naming the consumed set (objectui#5709 ruling). T… (objectui `8d58f46b4`) -- **minor** — **`ReferenceRailEntry` is now the spec's type, and the reference-rail `icon` key retires** (objectui#5494, maintainer ruling 2026-08-22). (objectui `23705b730`) -- **minor** — `'agenda'` leaves `CalendarViewMode` and the zod `CalendarViewModeSchema` (objectui#5740 — the value-level residue of objectui#5667's key-level convergence of `CalendarViewSchema`… (objectui `b55a34647`) -- **minor** — **BREAKING** — Retire `ActionParamDialog`: the `custom` barrel's second action-param dialog is removed, and the app-shell dialog is recorded as the surviving implementation (objectui#5685, maint… (objectui `72ffc3496`) -- **minor** — Localize the theme document types: `@object-ui/types` now owns `Theme`, `ThemeMode` and `ColorPalette` (objectui#5716 ruling, 2026-08-23). The spec retired its theme module (objec… (objectui `c9327c985`) -- **minor** — **BREAKING** — `CalendarViewSchema` (TS interface and zod mirror) converges on the registered `calendar-view` renderer's measured read set (objectui#5667, maintainer ruling option A — the render… (objectui `065bba746`) -- **minor** — Row predicates declare a canon: `record.*`. The bare shorthand and `data.*` now warn once, and are unchanged otherwise. (objectui `d1ab06f0f`) -- **minor** — The metadata designer's View column inspector now reads a column's identity — field key and label — in the ObjectStack canonical spelling only: `field` and `label`. The legacy Tan… (objectui `0e00da354`) -- **minor** — `AuthGuard`'s `requiredRoles` now matches against `user.positions` — the one published spelling (framework ADR-0090 D3) — keeping the `user.role` scalar fallback only for sessions… (objectui `686d5d92c`) -- **minor** — **BREAKING** — Stop showing an access-denied screen to a real administrator while their adminship is still resolving. (objectui `c8da8b326`) -- **patch** — Delete the dead `metadata-admin/previews/object-fields-bridge.ts` module, and the three prose references that still described it as wired. (objectui `dddb942d1`) -- **patch** — `appDesigner.fieldDesigner.formula` is retired — one row removed from each of the ten locale packs plus the designer defaults map, 11 lines, zero readers (objectui#6310). (objectui `00c665ef5`) -- **patch** — The form renderer no longer leaks `FormSchema.previousValues` onto the `
` DOM node (objectui#6396). (objectui `f24de8bbc`) -- **patch** — `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` now publish a stylesheet — `"./style.css"`, mapped to `dist/index.css` and compiled at build time from the package's own so… (objectui `062943f86`) -- **patch** — `MasterDetailForm` shows a config hint naming `relationshipField` for a detail collection whose child schema **loaded fine but could not be derived from**, instead of a permanent… (objectui `fd814d6f0`) -- **patch** — `SimpleObjectForm`: consult a declared `submitHandler` before the inline-fields carve-out (objectui `43ca9d5ee`) -- **patch** — `ObjectViewSchema`'s `table` and `form` slots now ship the members they promise (objectui#6269). Both were declared by deriving from the schema they document — `table?: Partial - -objectui range: `190fbd01d061...9602dc820450` diff --git a/.changeset/console-description-drop-fabricated-framework.md b/.changeset/console-description-drop-fabricated-framework.md deleted file mode 100644 index 0234950c90..0000000000 --- a/.changeset/console-description-drop-fabricated-framework.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/console': patch ---- - -npm `description` no longer names the fabricated `@objectstack/framework` - -`@objectstack/console`'s manifest `description` is what npm renders on the -package page, so it was the most-read of the three sites that named a package -nobody can install. The earlier pass corrected the other two — this package's -README and `packages/cli/src/utils/console.ts` — but left the manifest alone -because a published manifest was outside that change's declared file surface. -This is the third and last live site. - -The correction is the same one the README took: the mechanism is real and only -the name was wrong. There is no umbrella `@objectstack/framework` package (404 -on the public registry, and fabricated — nothing in this tree presents it as -enterprise or private, it is presented as the *default public* install). What -actually pins the two together is that `@objectstack/cli` declares -`@objectstack/console` as a dependency and both ship at one version from the -Changesets `fixed` group. - -```diff --Prebuilt Console SPA pinned to this @objectstack/framework release. Source of truth: … -+Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: … -``` - -"framework release" survives as the common noun the README already uses; what -is dropped is the `@objectstack/` scope that turned it into a package name. The -`@object-ui/console` source-of-truth half is unchanged — it was accurate. - -Text only: no code, no exports, no behaviour change. It carries a changeset -rather than the publish-nothing exemption because the manifest `description` -ships inside the npm tarball and is a published artifact. diff --git a/.changeset/console-readme-resolution-order.md b/.changeset/console-readme-resolution-order.md deleted file mode 100644 index 6fdd200661..0000000000 --- a/.changeset/console-readme-resolution-order.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Correct the published `@objectstack/console` README's account of how the CLI -resolves the Console SPA (#11262). `README.md` is in this package's `files` -array, so it is the page npm renders. - -The README claimed `resolveConsolePath()` "prefers `@objectstack/console` and -falls back to `@object-ui/console` when present — so cloud's Docker overlay -(which `cp -r`s its build over `node_modules/@object-ui/console`) keeps -working." Both halves are false against -`packages/cli/src/utils/console.ts`: - -- There is no `node_modules` fallback to the `@object-ui/console` npm - package. Strategy 1 (`require.resolve('@objectstack/console/package.json')` - from the app and from the CLI itself) and strategy 2 (direct - `/node_modules/@objectstack/console`) each require the resolved - `package.json` to be named `@objectstack/console`. The legacy package is - never consulted; the function's own header says so. -- `@object-ui/console` survives in exactly one branch — strategy 3, the - sibling-repo dev fallback, which matches the `name` field of a checked-out - `../objectui/apps/console` **source** tree. That is a source-checkout - probe, not a package resolution, and the corrected text keeps the two - apart. -- The trailing consequence was the load-bearing half: cloud and objectos - Docker images overlay into `@objectstack/console`'s `dist/`, so the README - was telling an operator their overlay works by a mechanism that no longer - exists. Replaced with the real overlay target. - -Documentation only — no runtime, type or export change. The removal of the -npm-package fallback was deliberate and is not restored here. diff --git a/.changeset/controlled-by-parent-chain-composition.md b/.changeset/controlled-by-parent-chain-composition.md deleted file mode 100644 index fc9e2cd353..0000000000 --- a/.changeset/controlled-by-parent-chain-composition.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -fix(plugin-security): `controlled_by_parent` composes across a chain — a child whose master is itself derived is no longer readable and writable org-wide (#11082) - -**BREAKING** access tightening, shipped as `minor` under the repo's -launch-window convention. It denies reads and writes that previously -succeeded — which is the whole point: they were never authorized by any -declaration, and the app author could not tell. - -`controlled_by_parent` (ADR-0055) resolves a detail's access from its master. -#5386 made that resolution fold in the master's ownership and its -`sys_record_share` grants, not just the master's RLS policies. It did not -recurse, and both halves it composes answer "no restriction" for a master that -is **itself** `controlled_by_parent`: - -- the RLS half is `null`, because a derived object authors no policy — - declaring `controlled_by_parent` *is* its policy; -- the sharing half is `null` too: `plugin-sharing`'s `buildReadFilter` opts out - of every model that is not `private`, and `effectiveSharingModel` maps - `controlled_by_parent` to `public`. - -Composed: `null`. The derivation's master query then ran as **system** with an -empty predicate and returned **every master row**, so a two-level chain was -enforced at level one and org-wide at level two. The write half failed through -a separate mechanism with the same result: the master gate asks `canEdit` on -the master row, `checkEdit` returns `abstain` for a `public`-mapped model, and -`abstain` is not `deny` — so it answered `true` for every master row. - -Both halves now walk the chain. The read derivation composes the master's own -`controlled_by_parent` filter as a third layer, and the write gate runs its -three master-edit legs on each hop until it reaches a master that governs its -own rows. The master set is therefore point-for-point equal to what a direct -read of the master returns, at every level, which is the equality #5386 -established for one level. - -This is **not** a blanket refusal for chained declarations: a detail whose -whole chain is reachable stays readable and writable, and the single-level case -is unchanged. Two guards bound the walk and both fail **closed**, never to "no -restriction": a metadata cycle is refused, and so is a chain deeper than 8 -links (a cost ceiling, not a supported-length statement — termination is -already guaranteed by the cycle guard). - -What an app may observe: a detail under a `controlled_by_parent` master that -was reachable before is now reachable only if the caller can reach the whole -chain above it. Apps whose masters are `private`, `public_read` or -`public_read_write` — every `controlled_by_parent` object authored in this -repo — are unaffected. - - diff --git a/.changeset/create-objectstack-honest-pm-probe.md b/.changeset/create-objectstack-honest-pm-probe.md deleted file mode 100644 index e244cd353f..0000000000 --- a/.changeset/create-objectstack-honest-pm-probe.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"create-objectstack": patch ---- - -fix(create-objectstack): stop reporting a failed pnpm probe as a deliberate npm choice (#11616) - -`detectPackageManager()` was `try { execSync('pnpm --version') } catch { return -'npm' }`, so every failure mode collapsed into one answer. `npm install` in the -scaffolder's output meant either *this machine has no pnpm* or *the probe -threw*, and nothing — no log line, no message — could tell the two apart. - -That second case is reachable on an ordinary developer machine, not just in -theory: `pnpm --version` resolves through Corepack and therefore depends on the -directory it runs in. Measured on one machine, one binary, two directories — -`10.31.0` inside a repo that pins `packageManager`, `10.33.0` outside it, where -Corepack has to resolve, and may have to fetch, a version nothing pinned. A -user who has pnpm installed but is on a slow or offline network was silently -told to run npm. - -The probe now reports why as well as what: - -- `probe: 'ok'` — pnpm answered, so pnpm is used (unchanged, silent). -- `probe: 'absent'` — no pnpm on PATH at all, so npm is a real choice - (unchanged, silent). -- `probe: 'failed'` — pnpm **is** on PATH and the probe still threw. npm is - used exactly as before, and the run now says so, naming the underlying - failure: `pnpm is installed but \`pnpm --version\` failed (); using - npm as a fallback.` - -**Which package manager a run uses is unchanged in all three cases** — it is -still pnpm if and only if the probe succeeded. The PATH lookup that separates -`absent` from `failed` runs only after the decision is already made and feeds -the message alone, so a miss there can change a warning's wording and never the -tool's behaviour. The only output that moves is one warning in a case that was -previously silent and wrong. diff --git a/.changeset/cross-schema-fk-qualification.md b/.changeset/cross-schema-fk-qualification.md deleted file mode 100644 index da49b7e68a..0000000000 --- a/.changeset/cross-schema-fk-qualification.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/objectql': minor ---- - -Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). - -`IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and -only when — the referenced parent table lives outside the introspecting session's resolution -scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's -database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot -cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a -separate key, never a conditional spelling. - -`convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target -carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` -(default `console`) instead of being wired to the bare name — which either resolved to nothing -or to a same-named table in the current schema, silently. The column is kept as a plain field so -its data stays visible. Foreign keys with in-scope targets keep producing identical lookup -fields. diff --git a/.changeset/crypto-context-producer-vocabularies.md b/.changeset/crypto-context-producer-vocabularies.md deleted file mode 100644 index 82d89205bb..0000000000 --- a/.changeset/crypto-context-producer-vocabularies.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/service-datasource": patch ---- - -docs(spec,service-datasource): `CryptoContext` documents the three producer vocabularies, and the AAD sentence narrows to the guarantee that holds (#12599) - -Documentation-only correction to the `ICryptoProvider` contract. No behavior -changes and no schema shape changes: `gen:schema`, `gen:openapi` and `gen:docs` -all regenerate byte-identically over this diff. It ships because the corrected -TSDoc is emitted into the published `.d.ts`, so it is what every consumer of -`@objectstack/spec` reads at the call site. - -`CryptoContext.namespace` / `.key` documented themselves as a settings -coordinate ("Settings namespace the value belongs to" / "Specifier key within -the namespace"), one layer below the three producers that actually construct -the type: - -| producer | `ctx.namespace` | `ctx.key` | where `handle.id` is recorded | -|---|---|---|---| -| `SettingsService` | settings namespace | specifier key | `sys_setting.value_enc` | -| the ObjectQL engine's secret-field path | **object name** | **field name** | a `secret:` ref on the business row | -| the datasource secret binder | caller-supplied, default `datasource` | datasource name | a `sys_secret:` credentialsRef | - -The prose now names all three, and `CryptoHandle.id`, `encrypt()` and -`rotateKey()` no longer describe `sys_setting.value_enc` as the general -destination of a handle. - -The load-bearing half is the AAD sentence. It read "Helps reject ciphertexts -that were copied across namespaces", which overstates what a binding over this -pair can provide: `(namespace, key)` is **one flat space shared by all three -vocabularies**, `sys_secret` declares the pair non-unique by design, and nothing -reserves a name in one vocabulary against another. The sentence is replaced by -the guarantee that actually holds — such a binding rejects a ciphertext swapped -between two coordinates *within one producer's vocabulary*, and does **not** -exclude a cross-vocabulary pair. The docblock records this as the contract's -present state and names the intended end state (a producer-discriminated AAD) -so the weak guard is not read as the designed one. - -The same settings-only prose on `DatasourceSecretBinderDeps.namespace` -("Settings namespace recorded on the secret row") is corrected in the same -change. - -Recorded under the maintainer ruling of 2026-08-27 on #12599 (Option A now, -with the producer-discriminated AAD recorded as direction in its own ADR). diff --git a/.changeset/crypto-posture-deployment-signal.md b/.changeset/crypto-posture-deployment-signal.md deleted file mode 100644 index b6ea4dcd68..0000000000 --- a/.changeset/crypto-posture-deployment-signal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/service-settings": minor ---- - -**Security:** `LocalCryptoProvider` selects its crypto posture from the deployment signal only. A test-runner variable inherited by a spawned server can no longer disarm the production key refusal (#11352). - -`detectMode` read `env.VITEST` as a vote for `'test'` posture: - -```ts -if (env.VITEST || env.NODE_ENV === 'test') return 'test'; -``` - -`'test'` is not a softer flavour of `'production'`. It is the branch that takes an ephemeral key, never touches disk, and **never refuses to boot** — and that refusal is the reason the class exists: minting a key at boot makes every previously-written `sys_secret` value (encrypted settings, `secret` fields, datasource credentials) undecryptable after the next restart or on another node, invisibly at encrypt time. So one runner variable decided whether a security gate ran at all. - -Runner variables are **inherited**. Vitest sets `TEST`, `VITEST`, `VITEST_MODE`, `VITEST_WORKER_ID` and `VITEST_POOL_ID` on its worker, and every process that worker spawns with `{ ...process.env }` receives them. Measured on this repo: a real `os serve` spawned that way booted with production auth and **test** crypto. `packages/cli/test/serve-node-env-production-default.e2e.test.ts` — a pin whose entire subject is *"unset `NODE_ENV` means production"* — ran that way for its whole life, and nothing said a word, because a gate that does not run prints nothing. It surfaced only incidentally, while closing the sibling `TEST` leak into better-auth's origin check one layer down. - -**What changes for you.** A process that boots with `NODE_ENV=production`, no `OS_SECRET_KEY`/`OS_DEV_CRYPTO_KEY`, no persisted key file, no `OS_CRYPTO_AUTOKEY` — and a runner variable in its environment — now **refuses to start** instead of running on an ephemeral key. That is the documented fail-loud guarantee arriving where it was previously skipped, not a new restriction: supply the key the refusal names. - -``` -OS_SECRET_KEY=$(openssl rand -hex 32) -``` - -**What does not change.** In-process unit tests still get `test` posture — ephemeral key, disk never touched. The `VITEST` read is deleted rather than narrowed because vitest sets both variables on the same worker (`prepareVitest()`: `process.env.VITEST = "true"; process.env.NODE_ENV ??= "test";`, repeated as `NODE_ENV: process.env.NODE_ENV || "test"` in each worker's env). In-process the two spellings are indistinguishable; they differ only for an **inheriting child**, which is precisely the defect. `NODE_ENV` remains the one signal, and a deployment that declares itself a test deployment still gets test posture. - -The whole class is now gated: `pnpm check:runner-env-posture` refuses `TEST`, `VITEST`/`VITEST_*` and `JEST_WORKER_ID` anywhere in product source, so the next author is told at authoring time rather than by an operator whose secrets stopped decrypting. `NODE_ENV` is deliberately not banned — it describes the deployment, and a deployment may declare itself a test deployment; a runner may not declare it on the deployment's behalf. diff --git a/.changeset/data-engine-datasource-def-catchup.md b/.changeset/data-engine-datasource-def-catchup.md deleted file mode 100644 index 3e512c9b4a..0000000000 --- a/.changeset/data-engine-datasource-def-catchup.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `IDataEngine`'s datasource-def contract catches up to the engine — `registerDatasourceDef` accepts `external.credentialsRef`, and `listDatasourceDefs` is declared (#12805) - -Additive contract catch-up to declared-and-enforced engine reality (#12758), -the fourth datasource-lifecycle member under the 2026-08-25 #11833 ruling's -item-4 precedent. `registerDatasourceDef`'s parameter now admits the -`external.credentialsRef` secrets-store handle the engine has accepted and -retained since #12758 — before this, a caller typed against the published -contract was refused with TS2353 at the consumer seam for a value the runtime -keeps (the engine's own typecheck was green either way, parameter bivariance). -The new optional `listDatasourceDefs?(): EngineDatasourceDef[]` member is the -read-back of the same registry, declared so a `sys_secret` reference sweep -(#12804) can ask the engine "which code-declared datasources hold a handle" -through the `'data'` slot contract instead of naming the engine class or -re-declaring a consumer-local structural type (the #11833 pattern). Both -members share the newly exported `EngineDatasourceDef` shape and stay -optional — only engines that own a datasource registry answer. No runtime -change; existing `IDataEngine` implementers and callers are unaffected. diff --git a/.changeset/data-engine-syncobjectschema-declared.md b/.changeset/data-engine-syncobjectschema-declared.md deleted file mode 100644 index d74c29ed7d..0000000000 --- a/.changeset/data-engine-syncobjectschema-declared.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-messaging': patch ---- - -`IDataEngine` declares the optional `syncObjectSchema?(objectName: string): Promise` member (#12482) — on-demand single-object physical schema sync: create/alter the object's table, or for a federated (external) object register its DDL-free read metadata (ADR-0015 §18). Additive contract catch-up under the 2026-08-25 #11833 ruling's item-4 precedent as executed by #12248: the member #12010's inventory left "not verified" is verified — implemented on `ObjectQL`, consumed cross-package by two service packages, both until now through consumer-local structural recovery (`service-datasource`'s `ConnectionEngineLike.syncObjectSchema?`, called per bound external object after its driver connects; `service-messaging`'s system-table provisioning via an `as unknown as` cast whose own comment recorded the member "lives on the concrete ObjectQL engine, not the contract"). FROM undeclared (consumers cast or re-declare structurally) TO declared-optional on `IDataEngine` (consumers read `engine.syncObjectSchema` directly and keep their runtime probes). `service-messaging` drops the now-redundant cast (behaviour unchanged). Optional, so existing `IDataEngine` implementers and test doubles are unaffected. No runtime change. diff --git a/.changeset/datasource-def-credentials-ref-retained.md b/.changeset/datasource-def-credentials-ref-retained.md deleted file mode 100644 index 0ebc44300c..0000000000 --- a/.changeset/datasource-def-credentials-ref-retained.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -feat(objectql): retain and expose `external.credentialsRef` on datasource definitions (#12758) - -`ObjectQL.registerDatasourceDef`'s parameter type carried only `name`, -`schemaMode` and `external.allowWrites`, so a caller passing a fresh object -literal with `external.credentialsRef` was refused by excess-property checking -(`TS2353`) — while the docs (`/docs/data-modeling/external-datasources`) -prescribe exactly that key on a code-declared datasource, and -`@objectstack/spec` has declared it all along on -`ExternalDatasourceSettingsSchema`, valid in every `schemaMode` (#8153). The -engine also exposed **no reader at all** onto its datasource index; its sole -consumer was the private write gate. - -Measured before anything was changed: nothing stripped the reference at -runtime. The writer stores the caller's `external` object whole, by reference, -and the package-manifest install path spreads the def straight through — so the -value was already in the index, unreachable to every typed producer and every -consumer. The defect was type-level, and the fix is a widening plus the -accessor that was missing. - -- `registerDatasourceDef` now takes the named, exported `DatasourceDef`, whose - `external` block carries `credentialsRef?: string` beside `allowWrites`. - Retention, not invention: the key is the spec's, and every shape that - compiled before still compiles. -- New `ObjectQL.listDatasourceDefs()` answers every definition the engine - holds, from both entry routes. Deliberately unfiltered — `credentialsRef` is - valid on a managed datasource too, so filtering by schema mode would hide - live handles from a `sys_secret` reference sweep, and under-reporting is the - direction that deletes live credentials. Each entry carries a copied - `external` block so a reader cannot reach through it and mutate the write - gate's own input. - -Why this matters beyond tidiness: a datasource declared **in code** never -reaches `sys_metadata`, so the cross-producer `sys_secret` reference union -(#12663) cannot see the handle it holds and must be handed the list by its -host. That makes the completeness of the union — the precondition an orphan -sweep's deletion predicate rests on — depend on every caller remembering to -pass a list. This moves the guarantee from process to mechanism. The union is -not rewired here; that is consumer-side work on a shipped contract and is -tracked separately. - -The write gate is untouched: it reads `schemaMode` + `allowWrites`, the new key -is inert to it, and both directions of the gate stay pinned. - -**Why `minor` and not `patch`.** Zero runtime behaviour changes, which is the -honest case for `patch` — but the bump describes the **contract**, not the -bytes executed, and this release adds public API three ways: a new public -method (`listDatasourceDefs`), a newly exported type (`DatasourceDef`), and a -widened accepted set on an existing public method (calls that were rejected at -compile time now compile). A consumer pinning `~` would receive new API under a -`patch`, which misdescribes the release. Nothing is removed, narrowed or -renamed, so no breaking-change declaration and no ADR-0087 entry arise; `minor` -is the additive-surface bump, not the launch-window breaking convention. diff --git a/.changeset/datasource-driver-handle-introspection-contract.md b/.changeset/datasource-driver-handle-introspection-contract.md deleted file mode 100644 index 7aa7cedd8b..0000000000 --- a/.changeset/datasource-driver-handle-introspection-contract.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/service-datasource": minor ---- - -feat(service-datasource): `DatasourceDriverHandle.introspectSchema` declares the spec introspection contract, so a mis-shaped custom driver fails to compile naming the wrong field (#11381, option C of the #11123 ruling) - -**BREAKING** for TypeScript hosts that build custom external-datasource -drivers, shipped as `minor` under the repo's launch-window convention for -breaking changes. - -`DatasourceDriverHandle.introspectSchema` — the seam every host-built driver -crosses, since the framework deliberately ships no driver-by-id registry — -was typed `Promise`. The `isPrimary` → `primaryKey` retirement -(#11124, shipped in 17.2.0) named the compiler as the channel that reaches -every affected consumer, but against an `unknown` return that channel -provably never fired: a host driver spelling the per-column primary-key flag -`isPrimary`, or returning `{ tables }` with no `dialect`/`introspectedAt`, -compiled clean, and the mis-shape surfaced only as a federated table whose -records silently could not be located or updated. - -The member now declares `Promise` — the one introspection -contract in `packages/spec` (`contracts/schema-diff-service.ts`). A -mis-shaped driver is refused at compile time, at the offending field: -`Property 'primaryKey' is missing in type '…' but required in type -'IntrospectedColumn'`, and on a fresh literal additionally `'isPrimary' does -not exist in type 'IntrospectedColumn'`. A driver that already returns the -spec shape — or a richer declared type extending it, the driver-sql / -objectql pattern (table-level `primaryKeys`, per-column `maxLength`) — -compiles unchanged. - -Runtime behaviour does not change. The `primaryKeyReader` compatibility belt -in `ExternalDatasourceService` keeps absorbing the retired spelling from -producers no compiler reaches (drivers already built against older versions, -plain-JS drivers, casts). Removing that belt is #11123 option B — a later, -separate step gated on this tightening being released and the retirement -being published — and is not part of this change. - - diff --git a/.changeset/dead-testing-cjs-not-published.md b/.changeset/dead-testing-cjs-not-published.md deleted file mode 100644 index e524b1ce69..0000000000 --- a/.changeset/dead-testing-cjs-not-published.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/metadata-core': patch -'@objectstack/service-cluster': patch ---- - -fix(metadata-core,service-cluster): stop emitting and publishing the CJS half of `./testing` (#13013) - -#13001 made both `./testing` subpaths ESM-only, dropping the `require` condition -that pointed at `dist/testing.cjs`. The build kept emitting those files and -`files: ["dist"]` kept packing them, so every release shipped bytes no exports -condition could reach. Measured with `npm pack --dry-run`, before → after: - -| package | files | unpacked | dropped | -|---|---|---|---| -| `@objectstack/metadata-core` | 22 → 16 | 3.3 MB → 3.2 MB | `testing.cjs` (28.0 kB), `testing.cjs.map` (48.4 kB), `testing.d.cts` (9.4 kB), `chunk-H2D6OJ76.cjs` (4.2 kB) + map (10.6 kB), `repository-*.d.cts` | -| `@objectstack/service-cluster` | 15 → 12 | 364.1 kB → 336.9 kB | `testing.cjs` (11.9 kB), `testing.cjs.map` (14.5 kB), `testing.d.cts` (794 B) | - -Nothing reachable changed. The whole ESM surface of both packages — `index.js`, -`testing.js`, their maps, the shared chunk, and every declaration the manifest -names — is **byte-for-byte identical** to the previous build (sha256, before vs -after). `index.cjs` changes only because what was a shared CJS chunk is now -inlined into the sole remaining CJS entry. - -Each `tsup.config.ts` becomes an array of two configs split **by format** — -ESM keeps both entries, CJS takes `src/index.ts` alone. The split is by format -and never by entry: `index` and `testing` share a chunk carrying the error -classes, and one config per entry would give `testing.js` its own copies, so -`ConflictError` reached through `@objectstack/metadata-core/testing` would stop -being the class thrown by `@objectstack/metadata-core` — which the published -contract suite asserts (`.rejects.toBeInstanceOf(ConflictError)`). - -`clean` moves out of tsup and into the `build` script (`rm -rf dist && tsup`). -tsup runs an array config through `Promise.all`, so the halves build -concurrently and a `clean` in either races the other's writes; the script-level -clean is also stronger than tsup's own, which preserves `*.d.{ts,cts,mts}` and -would therefore have left a stale `dist/testing.d.cts` behind on every rebuild -of an existing worktree. diff --git a/.changeset/declare-live-rest-config-keys.md b/.changeset/declare-live-rest-config-keys.md deleted file mode 100644 index 653779f70f..0000000000 --- a/.changeset/declare-live-rest-config-keys.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/rest': patch ---- - -fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) - -`api.enableSearch` was a live REST config key with no declared seat: -`RestServer.normalizeConfig` read it through `(api as any)` and honoured it -(`enableSearch: false` really unmounted the search endpoints), but no schema in -`packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, -its own parse **stripped** the key — measured: -`RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an -object with no `enableSearch` property at all — so any consumer of the parsed -config silently got search turned back on for a deployment that turned it off -(the ADR-0104 silent-strip class). It also forced #11637's construction-time -parse to be validation-only, discarding the parsed value. - -- `RestApiConfigSchema` now declares - `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the - runtime's existing default. The opt-out now survives the key's own - contract's parse (pinned), and a TypeScript author can write - `api: { enableSearch: false }` without a cast. -- `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the - newly declared `enableSearch`, the already-declared - `metadata.maskObjectFields` (its declared seat landed separately; the cast - was stale), and the long-declared `enableOpenApi` (stale residue from - before its declaration). `NormalizedRestServerConfig.api.enableSearch` is - now a required boolean like its siblings. - -No runtime behavior changes: defaults are identical (`enableSearch` on, -masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from -cast-reachable to declared = enforced. diff --git a/.changeset/declare-search-clone-response-contracts.md b/.changeset/declare-search-clone-response-contracts.md deleted file mode 100644 index 2fb9ee7dea..0000000000 --- a/.changeset/declare-search-clone-response-contracts.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/client': minor ---- - -Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) - -Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real -contracts. Their shapes were always stable and server-produced — they were declared inline on the -implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no -spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` -exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: -no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled -as part of this change). - -**`@objectstack/spec` (additive):** - -- `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, - `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: - `{ query, hits, totalObjects, totalHits, truncated }` with hits of - `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / - `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` - (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap - #8140 left a compile-time guard against, and the guard stands unchanged. -- `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body - of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s - structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). - No `droppedFields` member — unlike `createData`, the clone producer emits none. - -**`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is -now `Promise` and `data.clone` is `clone(…): Promise>` -(a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing -changes at runtime — no request, response, unwrapping or error path is touched — but code that -compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated -types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now -refused at compile time, which is the trap the erasure used to hide. - -The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to -the decision inbox with a consumer-survey reading per the same ruling. diff --git a/.changeset/declared-vs-enforced-permission-set-drift.md b/.changeset/declared-vs-enforced-permission-set-drift.md deleted file mode 100644 index 3103d5214a..0000000000 --- a/.changeset/declared-vs-enforced-permission-set-drift.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/rest': minor ---- - -Surface "declared ≠ enforced" on package-declared permission sets, and give -operators a sanctioned, audited way to discard a stale environment overlay. - -Field report: an rc→GA upgraded environment can freeze a package's -permission set at a stale snapshot while the shipped artifact keeps -shipping grant changes — silently, with only a boot log counter as a -signal. Two independent mechanisms can cause this, and either (or both -together) can be live on one row: - -- **overlay shadow** — a Studio permission-matrix save on a package-declared - set materializes a `sys_metadata` overlay that shadows every later package - edit to that set, forever, surviving redeploys and restarts; -- **provenance skip** — a `sys_permission_set` row whose `managed_by` column - predates package provenance tracking is treated as environment-authored - and never reconciled with the package. - -`sys_permission_set` now carries `drift_status` / `drift_detail`, recomputed -every boot, naming the set and the cause — a new "Needs Attention" Setup -list view surfaces only sets that actually differ from their shipped -artifact (an in-sync set is never flagged; `drift_status` stays `null`). - -A new "Discard Overlay" Setup action (`POST -/api/v1/security/permission-sets/:id/discard-overlay`) removes a stale -overlay and resyncs the record to the current artifact synchronously — the -supported, audited counterpart to the raw-SQL remediation the field report -had to use. It targets package-declared sets only: a set with no current -package declaration is refused, so a genuinely environment-authored set can -never be discarded by name collision. - -Boot-time auto-adoption of legacy rows and a bulk `os meta -adopt-permission-sets` command remain out of scope (2026-08-20 maintainer -ruling) — the manual SQL adoption recipe stays documented for the rc→GA -provenance-skip case; see the ops runbook. diff --git a/.changeset/declared-workspace-imports-gate.md b/.changeset/declared-workspace-imports-gate.md deleted file mode 100644 index 12170147bd..0000000000 --- a/.changeset/declared-workspace-imports-gate.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": patch -"@objectstack/service-automation": patch -"@objectstack/service-datasource": patch ---- - -feat(devx,datasource,automation): published `src/**` may only import workspace packages it declares (#10062) - -A package's non-test `src/**` was free to import any workspace package, -declared or not, and nothing checked it. The class was filed with one member -and a mitigation — the import was type-only, so nothing reached the emitted -JavaScript and rollup-plugin-dts inlined the declaration rather than naming an -unresolvable module. It grew to four members with no signal, and one of them -killed the mitigation: `service-automation/src/flow-precedence.ts` **value** -imports from `@objectstack/objectql`, which it does not declare, and because -the shared tsup config externalises only `dependencies`/`peerDependencies`, the -bundler answered by inlining objectql's implementation into -`service-automation/dist/index.js` — a second copy of another package's code, -kept correct by build configuration alone. - -`pnpm check:undeclared-dep-imports` is the gate, and the per-member fixes here -are decided one at a time rather than by a uniform policy — declaring makes a -coupling real and installable, routing it away removes it, and the two are not -interchangeable: - -* **`@objectstack/service-datasource`** now declares `@objectstack/driver-sql` - and `@objectstack/driver-memory` as **dependencies**. Both are loaded through - an *unguarded* `await import(...)` on the postgres, mysql, sqlite and memory - arms, so a consumer reaching one of those paths needed a package it was never - told to install, and would have met `ERR_MODULE_NOT_FOUND` rather than a - diagnosis. The three *guarded* driver arms — `@objectstack/driver-sqlite-wasm`, - `@objectstack/driver-mongodb`, `@objectstack/driver-turso` — are deliberately - left undeclared: each load sits in a `try`/`catch` that answers an absent - package with the fault, the consequence and the install command, and each - rides as an optional install. Declaring them would install them (turso drags - `@libsql/client`'s native bindings) and, measured on this branch, takes a live - assertion out of the tree: `default-datasource-driver-factory.test.ts` reaches - the missing-package arm with no stub precisely because the package does not - resolve from here. -* **`@objectstack/metadata-core`** now owns the ADR-0029 D9.6 provenance pair, - `isCodeArtifactBody` and `isTenantAuthored`, sunk out of - `@objectstack/objectql`'s registry by the same criterion as the write-verb - dispatch predicates and the audit governance table beside them: a second layer - needs the answer and the reverse import would either close a cycle or make the - consumer depend on the whole data engine for one predicate. `objectql` - re-exports `isCodeArtifactBody` from its original path, so its public API is - unchanged; `service-automation` imports it from `metadata-core`, which it - already declared, and its bundle no longer carries a copy of objectql's code. - -Two members stay recorded rather than remediated, because the tree already -carries the decision not to declare them together with its reason -(`@objectstack/runtime` → `@objectstack/driver-turso`, whose bare `import()` is -a host-replaceable default thunk under #6268; `@objectstack/rest` → -`@objectstack/objectql`, whose absence must degrade to `501 NOT_IMPLEMENTED` -rather than fail module load). Their ledger rows carry mechanical evidence and -go red the moment that evidence stops holding — in particular, a `type-only` -row reds on the day its import becomes a value import, which is exactly the -transition nothing caught the first time. diff --git a/.changeset/delete-reference-check-system-identity.md b/.changeset/delete-reference-check-system-identity.md deleted file mode 100644 index 949437a6f4..0000000000 --- a/.changeset/delete-reference-check-system-identity.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": minor ---- - -fix(objectql,spec): run the pre-delete reference check under the system identity (#12166) - -**Grade: `minor`, not `patch` — argued, because a permission-behaviour change -should not arrive as a bug-fix bump.** Configurations that returned `403` now -return `200`. Nothing gets more restrictive and no API changes shape, so this -is not `major`; but "records a role could never delete are now deletable" is a -security-surface accept-set change an upgrader must be able to see in a -release-notes heading, and a `patch` line is exactly where it would not be -looked for. The spec half ships `minor` alongside because the message catalog -gains two keys. - -Deleting a record runs the platform's pre-delete reference check, which issues -a `find` against every referencing object. That probe ran as the **calling -operator**, so a caller with full delete rights on the target but no read grant -on any referencing object got a blanket `403 PERMISSION_DENIED` — regardless of -whether a reference actually existed. An **empty** referencing table 403'd too. -The reporting deployment (`@objectstack/*@17.2.0`) measured 17 role×object -pairs where the UI shows a delete button that always fails, with the A/B -control that granting read-only on the referencing object — touching *nothing* -about delete rights — turned the identical operation into a `200`. - -It silently made "delete permission" mean "delete **plus read on every -referencing table**", a coupling invisible in the permission UI and impossible -for an administrator to self-diagnose: the refusal said only "You do not have -permission to perform this action." - -Referential-integrity actions are engine responsibility executed under system -identity on every mainstream platform — the RDBMS FK baseline, Salesforce -(lookup clearing and cascade delete documented as bypassing sharing), -Dataverse, ServiceNow, Odoo. Caller identity here was the outlier. Maintainer -ruling 2026-08-26, option A. - -**What changed.** The dependents probe now runs `sudo()`-shaped — -`{ ...context, isSystem: true }`, following the in-repo precedent in -`packages/objectql/src/integrity/dangling-reference-audit.ts`. The spread is -load-bearing: the caller's open transaction handle, **tenant scope** and -`userId` all survive, so the probe does not leave the caller's transaction and -does not read across the tenant wall. - -**What did NOT change.** Only the reference *check* switches identity. The -caller's own delete authorisation on the target is untouched; the `set_null` -`UPDATE`, the `cascade` `DELETE` and the target's own delete all still run as -the caller. A caller without delete rights on the target is refused exactly as -before — pinned in both directions, because a relaxation must not become a -hole. - -**Refusal copy.** Because the probe now sees rows the caller may hold no read -grant on, `DELETE_RESTRICTED` discloses the dependent **count** only when the -caller's own identity would have produced the same rows (compared on row -identity, so row-level narrowing counts too). Otherwise the count is withheld -and the refusal renders one of two new catalog keys, -`delete_restricted_opaque` / `delete_restricted_required_opaque` — the same -sentences minus `{{count}}`, in all four bundled locales. Without that, the -refusal would be an exact, repeatable cardinality oracle over a table the -caller may not read. The referenced **object** and the relation field are named -either way: those are declared metadata, and they are the whole of what makes -the refusal self-diagnosable. `dependentCount` is **absent** rather than `0` in -the withheld case — `0` would be a false statement about the rows. - -**Audit.** The elevation is filed with both halves, the Salesforce/Dataverse -ledger shape: `triggeredBy` = the deleting operator, `executedAs: 'system'`, -plus the referenced object and relation field — never a row id, value or count. -It is filed *before* the probe, so a refused or failed check is recorded too. -Declared limit: this is an engine **log** record, not a `sys_audit_log` row — -the elevated operation is a read, and `plugin-audit`'s read writer declares and -pins that a system-elevated read produces no row. A durable row belongs to the -plugin that owns that shape. - -**Upgrade note.** If a deployment was relying on the `403` as a de-facto delete -gate, that gate is gone. Under the industry baseline such usage is itself -non-standard and should be expressed as an explicit `deleteBehavior: 'restrict'` -on the relationship rather than as a read-permission side effect. No such -reliance was measured; the ruling records this as a known confidence gap. diff --git a/.changeset/delivery-not-eligible-gloss-both-surfaces.md b/.changeset/delivery-not-eligible-gloss-both-surfaces.md deleted file mode 100644 index 9cfc404898..0000000000 --- a/.changeset/delivery-not-eligible-gloss-both-surfaces.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`DELIVERY_NOT_ELIGIBLE`'s ledger gloss now describes every surface that raises it, not just `redeliver` - -The `ERROR_CODE_LEDGER` entry for `DELIVERY_NOT_ELIGIBLE` glossed the code as -*"delivery row is in a non-terminal state"*. That named one refusal on one -surface, and the code has since been reused on a second: `INotificationOutbox.ack` -refuses any row that is not `in_flight`, which covers an unclaimed `pending` row -**and** an already-terminal one. So the old wording was not merely incomplete — -it was backwards for half the code's uses, describing terminal rows as the -acceptable ones when `ack` refuses exactly those. - -The reuse itself is the ruled shape, not a defect: one concept — *this delivery -row's state does not permit the requested operation* — on two delivery surfaces, -with a second near-synonym code rejected for the vocabulary sprawl ADR-0112 -exists to prevent. Only the comment lagged. - -The gloss is now stated per surface, because the two refuse opposite halves of -the state space and no single status predicate covers both: - -- **`IHttpOutbox.redeliver`** (`HttpRedeliverError`) refuses a row that is NOT - terminal — `redeliver` means send this again, so it wants - `success`/`failed`/`dead`. It also raises the same code when the producer's - `RedeliverGuard` refuses or itself throws (fail-closed: "we could not check" - must never read as "allowed"), and when the terminal re-check at the write - misses because a dispatcher tick re-claimed the row mid-call. -- **`INotificationOutbox.ack`** (`NotificationAckError`) refuses a row that is - not `in_flight` — both the unclaimed `pending` row (the ack-as-cancel trap) - and the already-terminal one — plus the `SqlNotificationOutbox` compare-and-set - read-back that shows the claim was lost mid-ack. - -Comment only. No code is registered or removed, no wire value changes, and no -acceptance or refusal behaviour moves — `packages/spec` publishes -`src/**/*.zod.ts`, so the corrected gloss ships to consumers reading the ledger. diff --git a/.changeset/derived-capability-existence-read-batched.md b/.changeset/derived-capability-existence-read-batched.md deleted file mode 100644 index 5b6cf7b5cf..0000000000 --- a/.changeset/derived-capability-existence-read-batched.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -perf(security): batch the derived half of `bootstrapSystemCapabilities`, unnarrowed (#11520) - -`bootstrapSystemCapabilities` reconciles two halves. #11451 batched the CURATED -half into one `$in` read carrying the #8470 predicate and left the DERIVED -half — the union of every `systemPermissions` string that nothing declares — -reading one row at a time, so a rebuild cost `1 + derived` round trips. - -That residue was filed rather than fixed for a reason that has since expired. -Two objections stood: narrowing the derived read to the platform bucket answers -a different question and reverses ruled ground, and batching it *unnarrowed* -needed an unbounded read. #11518 removed the second one — `readNamePage` now -asks for one row more than its page budget and reports the overflow as -`truncated` = "could not answer", degrading loudly to the per-item read — so -the wide batched read became bounded without becoming a different question. - -The derived half now consults its own `buildExistingByName` index, built with -**no predicate**: the read emits `{ name: { $in: … } }` under `seedCtx()` -(`{ isSystem: true }`, the same context the per-item read used), and unscoped -`resolveOwnOrganizationRow` returns the FIRST row with no bucket filter — so -the index resolves to the same lowest-`id` row installation-wide that -`tryFind(…, 1)[0]` returned under #4363's `ORDER BY id ASC`. A steady-state -rebuild costs 2 reads at every derived size instead of `1 + derived`. - -⛔ The first objection still stands and is now pinned rather than only -documented: the derived read is **not** narrowed to `organization_id: null`. -Doing so would silence #8751's `platformStampedInOrg` anomaly signal in exactly -the case its doc says it is counted for, and would seed the platform bucket in -the case #8552 ruled must be left alone. A new test asserts the derived read's -key set is `name` and nothing else. - -One behaviour change, in the direction #10946 chose deliberately for the -curated half: a derived name whose existence read **cannot answer** is now -DECLINED (counted in `unreadable`) instead of being read as absent. The old -`tryFind` swallowed a failed read into `[]`, which routed the name to its -insert branch — a duplicate placeholder wherever the read failed but the write -did not, refused only where the unique index happens to exist, and silent -either way because the `blockedCurated` diagnostic is curated-only. The -`unreadable` counter and its summary warning now cover both halves; the warning -reports the whole definition set as its total rather than the curated count. diff --git a/.changeset/destructive-409-force-parity-compound-door.md b/.changeset/destructive-409-force-parity-compound-door.md deleted file mode 100644 index 2d39239e1a..0000000000 --- a/.changeset/destructive-409-force-parity-compound-door.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/rest": minor -"@objectstack/runtime": patch -"@objectstack/metadata-protocol": patch ---- - -**Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). - -`saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. - -The maintainer ruled a **split**, and the two halves are not the same fix: - -- **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. -- **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. - -For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. - -The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. diff --git a/.changeset/dialect-getter-knex-canonical-spellings.md b/.changeset/dialect-getter-knex-canonical-spellings.md deleted file mode 100644 index 538d06f905..0000000000 --- a/.changeset/dialect-getter-knex-canonical-spellings.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`SqlDriver` now recognises knex's own **canonical** client spellings, so `client: 'postgres'` and `client: 'sqlite'` no longer silently lose every dialect-specific behaviour (#11550). `SqlDriverConfig` is `Knex.Config & {…}`, so every client name knex accepts was already declared valid, while `isPostgres` / `isSqlite` enforced two literals each — and `postgres` is the canonical name of the dialect whose registered aliases are `pg` and `postgresql`, `sqlite` likewise for `sqlite3`. Nothing failed on the unrecognised spellings; the driver just emitted the wrong SQL. The sharpest case: `nowColumnDefault` fell through to a bare `CURRENT_TIMESTAMP` default on a `DATE` column, which resolves the calendar day in the **server's** timezone — the exact defect ("a UTC-12 server records YESTERDAY") that method's Postgres branch exists to remove. The three getters, the connect-timeout table, the pg wire-protocol set and the MySQL UTC session pin now derive from one identity source per dialect family instead of four hand-written lists that had already drifted apart. What an already-recognised spelling (`pg`, `postgresql`, `sqlite3`, `better-sqlite3`, `mysql`, `mysql2`) resolves to is unchanged, and `redshift` / `cockroachdb` keep pg **wire** recognition without gaining SQL-**emission** identity — that remains an open support-scope decision (#11756). diff --git a/.changeset/discovery-version-third-producer.md b/.changeset/discovery-version-third-producer.md deleted file mode 100644 index 3670aa940a..0000000000 --- a/.changeset/discovery-version-third-producer.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `getDiscovery()` serves a derived `version`, not the hardcoded `'1.0'` literal (#11235) - -`ObjectStackProtocolImplementation.getDiscovery()` filled `DiscoverySchema`'s "System -Identity" `version` field with the constant `'1.0'`. The other `DiscoverySchema` producer -— `HttpDispatcher.getDiscoveryInfo()` in `@objectstack/runtime` — filled the *same* field -with its own constant `'1.0.0'` until #10993 derived it. Two producers of one field -disagreeing with each other is what proves neither literal was ever a contract value: if -`version` were a contract, two producers would not each invent their own constant; if it -is not, it should not be hardcoded. That argument needs no opinion about what `version` -"should" be. - -It now resolves the same way its sibling does: an injected `OS_RUNTIME_VERSION` build -stamp, falling back to this package's own installed version, and `'unknown'` only if both -are unavailable — honest about not knowing rather than a plausible-looking constant. One -stamp, one meaning: a deployment that sets `OS_RUNTIME_VERSION` now gets the same answer -from both discovery producers and from `GET /health`, so the two can no longer drift. - -The resolver is a package-local ~10-line copy of `packages/runtime/src/runtime-version.ts` -rather than a shared import: `@objectstack/runtime` depends on -`@objectstack/metadata-protocol`, not the reverse, so importing it would invert the -dependency direction, and hoisting a helper into `@objectstack/types`/`@objectstack/core` -would widen two packages' published surface for two call sites (declined at #11235 -triage). `tsup.config.ts` gains `shims: true` for the same reason -`packages/runtime/tsup.config.ts` carries it — esbuild empties `import.meta` in a CJS -bundle, so without the shim `require('@objectstack/metadata-protocol')` would have fallen -through to `'unknown'` on every consumer. - -No schema shape changed, no field was added, and no export was widened — only where one -field's value comes from. Patch, matching the sibling fix. diff --git a/.changeset/dispatcher-meta-org-scope-url-spelling.md b/.changeset/dispatcher-meta-org-scope-url-spelling.md deleted file mode 100644 index 87f12fb139..0000000000 --- a/.changeset/dispatcher-meta-org-scope-url-spelling.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -The dispatcher `/metadata` transport folds the URL segment before deciding -organization scope — `/metadata/translations/:name` no longer writes to a -different partition than `/metadata/translation/:name` - -Two maps that must agree did not. `protocol.saveMetaItem` folds the path -segment through `canonicalizeMetaRequestType` → `META_URL_TO_SINGULAR`, the -**complete** spelling map, for storage. The dispatcher handed the same string -**raw** to `organizationIdForMetaWrite`, whose `declaresOrgOverride` tolerates -only the manifest-collection spellings — incomplete by design. - -For the two URL-only spellings of `allowOrgOverride: true` types the two -answers diverged. `translation` has no manifest collection key at all; -`email_template`'s is the camelCase `emailTemplates`, so the snake_case plural -the registry derivation adds is URL-only too: - -``` -PUT /metadata/translation/:name → org-scoped row (correct) -PUT /metadata/translations/:name → env-wide row (the defect) -PUT /metadata/email_template/:name → org-scoped row (correct) -PUT /metadata/email_templates/:name → env-wide row (the defect) -``` - -Storage folded both spellings to the same canonical type, so the rows differed -in `organization_id` alone: one item in two partitions, addressed by spelling. -Measured end-to-end through the real dispatcher, protocol and repository — -writing an item under both spellings left **two** `sys_metadata` rows where -there should be one, and the env-wide one is shadowed by every read the -org-active author makes. Persisted, receipted 200, served by nothing. - -`GET /metadata/:type/:name/published` is the smaller second site of the same -class. After the layered overlay consult misses, the fallback reads the -code/package store, which is keyed by canonical type; handed the raw segment it -answered **404** under a recognised plural for an item the singular twin -answered **200** for. - -Both sites now fold through `canonicalMetaUrlType` at the boundary — the -correction the REST `/meta` doors already carry, and the one -`metadata-url-spelling.ts` mandates ("folding happens at the boundary and only -there; the layers below keep reading the single canonical singular"). ⛔ Not by -widening `declaresOrgOverride`: a predicate below the boundary consuming the -URL spelling contract is the repair that module's header forbids. - -Only the scope **argument** is folded. The request `type` stays the raw -segment, exactly as the REST doors leave it — the protocol boundary folds it -itself, and two pre-folds would hide a drift between them from the protocol's -own tests. A type the contract does not map (a plugin-registered kind such as -`webhook`) still reaches the store verbatim: the fold is a lookup, never a -spelling guesser. - -⚠️ Whether real callers reach this transport with plural spellings has **not** -been measured. The REST transport was the measured, user-visible surface; this -one is corrected so the class is closed on both transports rather than one. diff --git a/.changeset/dist-freshness-refusal-derives-package-label.md b/.changeset/dist-freshness-refusal-derives-package-label.md deleted file mode 100644 index c168523726..0000000000 --- a/.changeset/dist-freshness-refusal-derives-package-label.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the dist-freshness refusal derives its package label and build remedy from `pkgDir` (#11250) - -`inspectDistFreshness()` / `inspectBundleFreshness()` in `packages/spec/scripts/lib/dist-freshness.ts` -already take an arbitrary `pkgDir`, but their refusal `cause` strings and the `pnpm --filter build` -remedy line hardcoded `packages/spec` / `@objectstack/spec` regardless of it. Every real caller passed -`SPEC_DIR` until #10969 gave `check:skill-examples` a second surface (`packages/client-react` / -`packages/client`) — confirmed live: a stale-dist refusal on that surface named `packages/spec` while -`packages/spec` was freshly built and `client`/`client-react` were the actually-unbuilt packages, so -following the printed remedy verbatim rebuilt an already-fresh package and re-red identically. - -Both cause strings now interpolate a `packages/` label derived from `pkgDir`'s own path (falling -back to the raw path when the shape doesn't match), and the build-remedy line now reads -`package.json#name` from `pkgDir` (falling back to the same label when it's missing or unparsable). The -freshness verdict itself, and every caller's own `rerun` argument, are unchanged — this is diagnostic -text only. diff --git a/.changeset/doc-when-predicate-cel-fence.md b/.changeset/doc-when-predicate-cel-fence.md deleted file mode 100644 index 8820238254..0000000000 --- a/.changeset/doc-when-predicate-cel-fence.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -Judge `visibleWhen` / `readonlyWhen` / `requiredWhen` examples in the docs corpus -as CEL — where the enclosing structure says which layer they are about - -`{/* os:check */}` blocks are type-checked by `tsc --noEmit`, and every CEL -string is the same type as every other CEL string, so -`visibleWhen: "record.status != 'closed' && user.hasRole('admin')"` type-checked -perfectly. `hasRole` is a CEL function that exists nowhere — it is in no stdlib -registry and on no contract — so the predicate faults at runtime, and a -field-level `visibleWhen` fault is fail-**open**: `resolveFieldRuleState` -evaluates visibility with `fallback: true`, so the element the author wrote the -predicate to hide is shown to everyone who copies the page. That is not a -hypothetical shape — a shipped doc taught it (#11034 fixed the instance). - -`check:doc-formula-expressions` gains this as a third scan surface rather than a -second gate, because two gates with opinions about one contract is the thing -Prime Directive #12 exists to prevent. The verdict is imported whole: syntax, the -unknown-function catch and the bare-reference rule come from -`@objectstack/formula`'s `validateExpression`, and the closed-root rule comes -from `fieldRuleRootIssue` — the same two the metadata walk applies to the same -slot, in the same order, in the same words. - -**The layer is decided first, and a layer that cannot be decided is skipped and -printed.** `visibleWhen` is one key spelling several unrelated contracts, and the -binding root really does differ: an object field binds `record` + `previous` -(+ `parent`), a per-option predicate binds `record` plus the host predicate scope -including `current_user`, a page component binds the user roots and `app`, and a -flow-screen field **flattens its own field names to top level**. A gate keyed on -the key alone would have gone red on -`content/docs/automation/flows.mdx`'s correct `visibleWhen: -'createOpportunity == true'` and on `content/docs/ui/pages.mdx`'s correct -`'sales_manager' in current_user.positions` — and a gate whose reds are wrong is -worse than no gate, because it teaches people to add ignores. - -So admission is structural and schema-backed, never keyed on the key: a -`Field.*({ … })` factory call, or a raw field definition carrying `type:` inside -an object-literal `fields:` **map**. The map-versus-array test is the load-bearing -half and it is read off the schemas — `ObjectSchema.fields` is -`z.record(name, FieldSchema)` while `FormFieldSchema` and `ScreenFieldConfigSchema` -are both `z.array(…)`, so a `fields:` map is the object-field layer and nothing -else, and a `fields:` array is exactly the case that cannot be told apart. - -**The skip list is printed and counted on every run, including green ones.** A -gate that skips in silence is the same false-green one level up, so the summary -names every skipped site and why. Measured on the corpus as it stands: 23 -text-level `*When:` occurrences, of which 13 are admitted and judged, 7 are -listed as skipped, and 3 are the ADR quoting `field.zod.ts`'s schema -(`visibleWhen: ExpressionInputSchema.optional()`) rather than authoring a -predicate. Three of those seven were invisible to an AST-only walk — a bare -`visibleWhen: "…"` line at statement position is a labelled statement, not a -property — so a text-level tripwire reconciles the two counts and any site the -parser never surfaced is listed rather than dropped. - -`@objectstack/lint` newly exports `fieldRuleRootIssue` and -`FIELD_RULE_BOUND_ROOTS`. The field-rule root decision was a closure inside -`validateStackExpressions` — correct while it had one caller, and exactly how a -second caller comes to own a dialect of a rule instead of the rule. Behaviour is -unchanged: the metadata walk now calls the extracted function and its 2271 tests -pass untouched. diff --git a/.changeset/docs-build-suppress-server-sourcemaps.md b/.changeset/docs-build-suppress-server-sourcemaps.md deleted file mode 100644 index 58c7047995..0000000000 --- a/.changeset/docs-build-suppress-server-sourcemaps.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/docs": patch ---- - -build(docs): stop emitting the 348 MB of server source maps the OOM-killed build was paying for (#12711) - -Every `objectstack.ai` production deploy on 2026-08-27 died with `exit 137` and -Vercel's `errorCode: "out_of_memory"` on a 4-core/8192 MB build machine. The -build now declares `experimental.turbopackSourceMaps: false`, which suppresses -260 map files totalling 348 MB that no production serverless function reads. - -The knob matters because of **where** the kill lands. Every failing log places it -between `Creating an optimized production build ...` and `Compiled successfully`, -with no output in between — inside the Turbopack compile phase, which the two -knobs already present cannot reach: - -- `experimental.cpus: 2` bounds static-generation workers that have not spawned - yet when the process dies. -- `NODE_OPTIONS=--max-old-space-size` bounds V8's old space, while Turbopack - allocates from Rust outside it. - -Measured on a local cold build of all 403 pages, as peak single-process RSS: - -| config | peak | compile | -|---|---|---| -| before | 5191 MB | 22.6s | -| **`turbopackSourceMaps: false`** | **4757 MB** | **19.5s** | -| `turbopackScopeHoisting: false` | 4806 MB | 19.1s | -| `--max-old-space-size=2048` | 4734 MB | 18.0s | -| `turbopackFileSystemCacheForBuild: true` | 4779 MB | 22.2s | - -Only the first row moves anything; the rest are noise, which is the measurement -that retires them as candidates rather than leaving them to be re-tried. - -Set explicitly on purpose. Next documents this flag's build-time default as -following `productionBrowserSourceMaps` (false), but the server-side maps are -emitted regardless — naming it is what suppresses them. - -Deliberately not paired with `turbopackMinify: false`, which takes a further -972 MB off the peak (3785 MB) and 3s off the compile: it inflates client JS from -5.8 MB to 16 MB (+176%), a cost every reader pays on every visit to save memory -in a machine they never touch. Confining minification to the server side — where -the memory actually goes, 589 MB of server chunks against 5.8 MB of client — is -not available: `experimental.serverMinification` is read only by -`dist/build/webpack-config.js`, never on the Turbopack path. - -No output change beyond the absent maps: same routes, same 1221 prerendered -paths, same rendered bytes. - -**This buys margin; it does not prove the ceiling is cleared.** The measurement -above is macOS/arm64 and the build container is Linux/x86_64, where the same -phase was measured at 7.6 GB (#12683) against local 4.7 GB — the ratio is the -transferable part, and −8.4% onto 7592 MB leaves roughly 15% headroom, which is -thin. #12683's option A (a larger build machine) is unaffected by this change and -remains the answer if the next production build still dies. diff --git a/.changeset/docs-category-index-cards-declared-pages.md b/.changeset/docs-category-index-cards-declared-pages.md deleted file mode 100644 index f2ba123598..0000000000 --- a/.changeset/docs-category-index-cards-declared-pages.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the generated docs category index cards the pages its `meta.json` declares (#11260) - -`content/docs/references/security/index.mdx` carded four of the five pages the -`meta.json` beside it declares. The fifth, `misc`, was generated and routed in -the sidebar — and unreachable from the one page whose job is to reach it. - -Both files come out of one `gen:docs` run, from two enumerations of "the pages -of this category" that disagreed about exactly one bucket: - -- `meta.json` was built from the pages the run **emitted**, which is where a - published schema that no `.zod.ts` accounts for lands (`security/` declares - two in plain `.ts` files, so they fall to the `misc` catch-all); -- the card grid was built from the `.zod.ts` files **on disk**. - -`misc` has no `.zod.ts` behind it by definition — the generator says so twice, -and `sourcePathFor` returns nothing for it precisely so the page prints no -invented "Source:" line — so it was *structurally* absent from the second -enumeration. The card loop never considered it, which also means the -`wasEmitted` guard that the loop's own comment leaned on ("This aligns the -index with `meta.json`") never ran for it. The comment was wrong in the shape -that reads as verified: it named the invariant while the code held it by -coincidence, for the 13 categories where two independent enumerations happen to -agree. - -The grid now iterates the list `meta.json` was built from, and keeps the -`wasEmitted` guard — a `.zod.ts` whose schemas are all unrepresentable in JSON -Schema still cannot be carded into a dangling 404. Because both files now read -one list, that guard can no longer thin the grid silently: a declared page the -run did not emit stops the build naming it. The stated invariant is true by -construction rather than by coincidence, which closes the class instead of -special-casing `misc`. - -The regenerated output is one line: `security/index.mdx` gains its `misc` card -(with no "Source:" line, correctly). The other 13 category grids are -byte-identical. - -An all-`misc` category — every published schema in the catch-all — would have -rendered an **empty** `` grid under the old loop, with every zod-derived -slug filtered out and `misc` never considered. No such category exists in the -repo, so no emitted file can pin it; the rule moved into -`scripts/lib/category-index.ts` so it can be asserted directly, and an empty -grid is now unreachable from a non-empty declaration. diff --git a/.changeset/docs-graphql-stale-references.md b/.changeset/docs-graphql-stale-references.md deleted file mode 100644 index 7bc9a8a910..0000000000 --- a/.changeset/docs-graphql-stale-references.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** removes stale GraphQL references and stale hand-typed header provenance from generated and hand-kept protocol docs (#10834, #10833). - -GraphQL was retired as a product surface some time ago: `packages/spec/src/api/` has zero GraphQL sources, the `/graphql` HTTP route was removed from the dispatcher (out of the product plan, #2462 follow-on), and `graphql` was never actually a `CoreServiceName` — it only ever existed as a stray entry in this table and in metadata-protocol's discovery table (see the comment above `SERVICE_PROVIDER_TABLE` in `core-services.zod.ts`). Two places in the package still asserted otherwise: - -- The generated `content/docs/references/index.mdx` API Protocol blurb read "REST/GraphQL contracts, …". The source is `CATEGORY_BLURBS.api` in `packages/spec/scripts/build-docs.ts`; fixed there and regenerated with `gen:docs` — no hand-edit to the generated `.mdx`. -- The hand-kept `packages/spec/llms.txt` (no generator; ships in the npm tarball per `files`) listed an `IGraphQLService` contract (execute, subscribe) under Service Contracts. `IGraphQLService` is declared nowhere in `packages/**/src` — verified before removal. Deleted the row rather than marking it `**DEPRECATED**` like the neighbouring `IUIService` row: that precedent fits a contract that has a replacement to point readers at; GraphQL has none — it's out of the product plan, not superseded by another contract — so a deprecation note would invent a migration path that doesn't exist. - -Also dropped this file's hand-typed `Schema Count` / `Last Updated` header lines (`171 Zod schemas, 191 test files, 5,157 tests`, `2026-02-12`) rather than refreshing them. Measured against the current tree: `packages/spec` now publishes 1,585 schemas (per the freshly generated `content/docs/references/index.mdx` root index) across 418 `*.test.ts` files — both roughly an order of magnitude past what the header claimed. Since this file has no generator (confirmed by the filer) and nothing re-verifies these numbers on change, a refreshed count would start drifting again on the very next PR that touches the package; removing the assertion is more honest than restating a number this file has no mechanism to keep true. Whether `llms.txt` should be generated at all is a larger follow-up left to the PM, not decided here. - -Graded rather than skipped: `llms.txt` ships in the `@objectstack/spec` npm tarball (`files`, enforced by `check:published-files`), so this prose change reaches consumers the same way the precedent in #10669 (`skill.tools` docblock) did. diff --git a/.changeset/docs-ignore-build-step.md b/.changeset/docs-ignore-build-step.md deleted file mode 100644 index a4fc9a22ac..0000000000 --- a/.changeset/docs-ignore-build-step.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/docs": patch ---- - -build(docs): stop rebuilding the docs site on every push to `main` (#12743) - -Every push to `main` rebuilt the documentation site, and almost none of them -changed what it renders. Measured over one week across the team: - -| project | production builds | build-minutes | avg | -|---|---|---|---| -| **objectstack (docs)** | **228** | **2835 (98.6%)** | **12.4 min** | -| objectui | 123 | 36 | 0.3 min | -| hotcrm | 42 | 5 | 0.1 min | - -The team runs `concurrentBuilds: 1`, so an 18-second `objectui` build queued -behind a 12–46 minute docs build; the queue reached **92 deployments, the oldest -34 hours old**. At 4 vCPU those docs builds cost roughly **$171/month** against a -$20 included allowance, and 168 of the 228 were failures, so most of it bought -nothing. - -`apps/docs/vercel.json` now declares an `ignoreCommand` (which overrides the -dashboard's Ignored Build Step, moving the rule into version control where it is -reviewable and revertible). `scripts/vercel-ignore-docs.sh` decides: - -1. non-production → skip (unchanged from the rule it replaces) -2. `content/**` or `apps/docs/**` changed → build -3. otherwise → ask turbo whether the docs dependency graph is affected -4. anything indeterminate → **build** - -**Step 2 is not redundant with step 3, and dropping it would silently stop -publishing documentation.** `turbo --filter=...[range]` computes affected -packages *by package directory*, and this repo's MDX lives at the repo root in -`content/`, outside the `apps/docs` boundary. `turbo.json` does list -`"$TURBO_ROOT$/content/**"` under `@objectstack/docs#build`'s `inputs`, but -`inputs` only feeds the cache hash — it does not widen the affected-package -calculation. Verified on `main`: commit `1265f12b` touches only -`content/docs/api/client-sdk.mdx`, and a dependency-graph check alone answers -SKIP for it. - -The asymmetry in step 4 is the point. A wrong "build" costs a few build-minutes; -a wrong "skip" leaves the site quietly stale with no error anywhere. So a missing -`VERCEL_GIT_PREVIOUS_SHA`, a shallow clone that cannot reach it, an unparseable -turbo verdict, and a non-0/1 exit from turbo all build. - -Deliberately not `npx turbo-ignore`, which #12698 suggested: it is deprecated -upstream ("Use `turbo query affected` instead") and derives its own comparison -range, falling back to `[HEAD^]` when it cannot read Vercel's git environment — -silently answering a different question than the one asked. The range is named -explicitly here instead. - -`scripts/vercel-ignore-docs.selftest.sh` pins all six cases against real commits -from this repo's history, including the `content/**` one. diff --git a/.changeset/docs-multishape-variant-subtables.md b/.changeset/docs-multishape-variant-subtables.md deleted file mode 100644 index 19835654ec..0000000000 --- a/.changeset/docs-multishape-variant-subtables.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): reference pages carry every variant's `.describe()` text when a property opens more than one object shape (#12316) - -The designed-and-measured remainder of #11601. #12309 gave a property that -opens exactly ONE nested object shape a `### Nested Shape:` table, and refused -a property whose type is a union of TWO OR MORE object shapes — there was no -single "the shape of this property" for a heading to name, and naming one -would have meant a variant index, i.e. a second addressing notation. Those rows -kept the collapsed signature cell, and the cell has no description column, so -their nested `.describe()` text stayed exactly as unreachable as #11601 found -it. - -**The population, re-measured on `origin/main@7bd6447`.** 28 of the 8604 -rendered property rows open two or more shapes, and **all 28** carry describe -text on at least one variant: `ui/App.navigation`, -`ui/NavigationArea.navigation` and `ui/NavigationContribution.items` with nine -variants each, `data/ConditionalValidation.then` / `.otherwise` and -`system/CRDTMergeResult.state` with five, `system/ChangeSet.operations` / -`.rollback` with seven, down to four two-variant rows. The census is unchanged -from the one #11601 recorded when it deferred them. - -**What is rendered now.** One sub-table per variant, each under the same -`### Nested Shape:` heading in the same position — no new grammar, and no new -heading level, so the single-h1 invariant and the module-header numbering are -untouched. The accessor gains a SELECTOR segment, spliced in where the union -sits in the wrapper stack rather than appended to the finished path, so -`StateMachine.on[string][option 3][number]` reads left to right as *the record -value, its third option, an element of it* — the reading `[string][number]` -already had. - -Two spellings, in preference order: - -- **`[type='sidebar']`** where the union has a discriminant — a key every - shape-bearing variant pins to a *different* literal. It states what the - author writes to select that variant, in the same `formatLiteral` spelling - the Type cell prints two lines above, and it is stable: reordering the union - or adding a tenth variant moves no existing heading and breaks no existing - anchor. 22 of the 28 rows have one. -- **`[option 2]`** for the six that do not — deliberately the word the union - branch has printed under `### Union Options` since long before this, rather - than a bare `[2]`, which in a stack of `[number]`/`[string]` segments would - read as a tuple index into the property's own type. It counts position in the - union *including* arms that open no shape, so the number is checkable against - the `string | { … } | { … }[]` cell the table sits under. - -The distinctness half of the discriminant test is load-bearing: a union whose -arms pin the same `const` has no discriminant by this rule, because answering -one would emit two identical headings — two identical anchors on one page, the -defect the `Schema.key` qualification exists to prevent. - -Every bound #11601 set still holds. Depth stays at `SHAPE_DEPTH_LIMIT = 1` — -what was lifted is the multi-shape *refusal* at level 1, not the budget, so a -shape nested inside a variant is exactly as unreachable as it was inside a lone -shape. "Only where there is text to publish" is now decided **per variant**, -which is the same rule one level finer: `ui/FormView.submitBehavior` opens four -shapes and one carries prose, so it gets one table, not four. And a variant -table still relocates no vocabulary — it is a third position for those keys. - -A property opening exactly one shape gains no selector segment, because the -stamp is conditioned on the union's own yield and not on the row's: all 1469 -single-shape headings #11601 published are byte-identical. The regenerated tree -is **purely additive** — 15 files, **+1397 / -0** lines, no reordering. diff --git a/.changeset/docs-nested-item-shape-tables.md b/.changeset/docs-nested-item-shape-tables.md deleted file mode 100644 index 132459d05d..0000000000 --- a/.changeset/docs-nested-item-shape-tables.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): reference pages carry a nested item shape's `.describe()` text instead of collapsing it into a signature cell (#11601) - -`build-docs.ts` renders a property whose type is an inline object as a -one-line signature — `{ label: string; icon?: string; visibleWhen?: string | -object; value?: string; … }[]` — into a table cell that has **no description -column**. Every `.describe()` an author wrote on a key of that shape was -therefore unreachable from the reference page: not truncated, not marked, -absent. `page:tabs`'s item-level `visibleWhen` carries a ~600-character -contract note whose whole point is that its evaluation environment is **not** -the page-component `visibleWhen` of the same name, and -`content/docs/references/ui/component.mdx` rendered that row with an empty -Description cell. - -The loss was invisible from both sides. `check:docs` compares generated output -with committed output, so it is green forever on prose neither side contains — -measured on the tree before this change, adding a `.describe()` to a nested -item key produced a **zero-line** `gen:docs` diff. - -**The population, measured on the emitted tree.** 1293 property rows across -566 published schemas and 13 of 14 categories open a nested shape; 1208 of them -have at least one key carrying describe text, 7502 described keys in total, -~473 KB of authored prose that reached no page. - -**What is rendered now.** A property that opens exactly one shape, and whose -shape has at least one described key, gets a `### Nested Shape:` table directly -under the Properties table — the same position, addressing and heading level -the `### Allowed Values:` relocation has used since #6225, so the page gains no -second grammar. The heading names the shape with a TypeScript indexed accessor -(`PageTabsProps.items[number]`, `Object.fields[string]`), which is a real -spelling rather than a sigil invented for the docs. - -Four bounds, each measured rather than chosen: - -- **One level**, matching the `SHAPE_DEPTH_LIMIT` budget a cell already spends. - A nested table opens no table of its own. -- **Only where there is text to publish.** A shape whose keys carry no - describe text keeps its cell; a table there would restate the cell in more - space. -- **A union of two or more object shapes keeps its cell.** There is no single - "the shape of this property" to name — the same reason `formatPropertyType` - refuses to relocate a vocabulary out of `Enum<…>[]`. -- **A nested table does not relocate vocabularies.** It is a second position - for those keys, so it elides them the way a `{ … }` summary does. Without - this rule the 288-member `ApiError.code` vocabulary was re-listed under every - nested `error` shape — 20,260 bullet lines across the tree, `api/metadata.mdx` - alone +6097. - -Tombstoned keys are rendered in a nested table, unlike in the cell above it: -`retiredKey()` puts the whole `[REMOVED]` migration prescription in -`description`, and a signature has no column to carry it. - -The regenerated tree is **purely additive** — 143 files, +14195 / -118 lines, -and every one of the 38177 pre-existing lines is still present byte for byte -(the 118 are re-ordering around the inserted sections, not removal). diff --git a/.changeset/docs-root-meta-declared-pages.md b/.changeset/docs-root-meta-declared-pages.md deleted file mode 100644 index a79adecf96..0000000000 --- a/.changeset/docs-root-meta-declared-pages.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the generated docs root sidebar lists categories from the same declared page list `meta.json` and the category index already agree on (#11482) - -`build-docs.ts` writes the docs tree from what should be one answer to "which -categories/pages exist", but the ROOT `content/docs/references/meta.json` (§3 -— the sidebar's top-level category list) was still answering it a third way: - -- a category's own `meta.json` (§2) is built from the pages the run - **emitted**; -- that category's `index.mdx` card grid (§2.5) reads the SAME declared list - (#11260) — no longer a second, independently-derived enumeration; -- the root `meta.json` (§3), until now, filtered on `categoryZodFiles` — the - `.zod.ts` files found **on disk** — a third, independent enumeration. - -A category whose published pages all come from plain `.ts` files rather than -`.zod.ts` ones (the `misc` catch-all class `security/misc` proves is real) has -zero `.zod.ts` files while still publishing a page, a `meta.json` and an -`index.mdx`. The old filter would drop such a category from the sidebar even -though it is fully generated and routed everywhere else — a folder complete on -disk and unreachable from the nav. - -**No category is in that state today** — all 14 have at least one `.zod.ts` -file — so this was a latent defect with no live instance, and the regenerated -root `meta.json` is byte-identical. The filter now reads `categoryMetaPages`, -the same map §2.5 already reads, so all three files answer from one list -instead of three that happen to agree today. The rule moved into -`scripts/lib/root-meta.ts` (`rootCategoryDirs`), pinned directly with the -all-`misc`-category shape that has no instance in the repo — the same move -#11260 made for the category card grid, for the same reason: the defect's -output is an ABSENT sidebar entry, which `check:docs` cannot see any more than -it could see an absent card, and the edge that has no live instance cannot be -pinned from emitted output at all. diff --git a/.changeset/doctor-organizations-hint-pinned.md b/.changeset/doctor-organizations-hint-pinned.md deleted file mode 100644 index 94a89c0021..0000000000 --- a/.changeset/doctor-organizations-hint-pinned.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -chore(cli): pin the multi-org runtime name `os doctor` prints, so a roster rename cannot drift it silently (#12464) - -`doctor.ts`'s `TENANCY_POSTURE_FIX_HINTS` repeated `serve`'s `isolated` posture -sentence verbatim, carrying its own bare `@objectstack/organizations` literal -under no check at all. PR #12463 single-sourced and pinned every operator-facing -occurrence in `serve.ts`; this copy was outside that card's file surface and was -correctly left alone. The defect it left behind is the same class: a roster-key -rename would leave `os doctor` naming a package that boot no longer resolves, -**with every gate green** — the roster pin only ever sees the declaration, and -nothing read this hint table's text. - -The `isolated` hint now interpolates a module-level `ORGANIZATIONS_RUNTIME_PKG` -in `doctor.ts`, and a new sibling test pins it on two legs: the **rendered** -bullet compared whitespace-included against text built from that declaration -(the #12463 shape — asserting what the operator sees, never that a constant -appears in source), and a **roster** leg asserting that declaration IS a key of -the spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES`. The roster leg is the load-bearing -one: without it the hint and its expectation move together under a rename and -nothing goes red. - -**This deliberately does not single-source the spelling, and the constant's -docblock says so at the site.** The literal is still declared three times (the -roster key, `Serve.ORGANIZATIONS_RUNTIME_PKG`, and now this const). The roster -cannot supply the name — it is keyed BY package name, its row type carries no -`package` field by design, and its own header records that it is "not a -resolution registry" — and importing `serve`'s export into a diagnostic command -would be a worse coupling than the duplication it removes. What changes is that -this copy can no longer drift in silence. The duplication ends properly when a -shared tenancy-hint table lands (tracked at #12492); the docblock carries that -deletion condition. - -**No behaviour change.** The declared value is byte-identical to the literal it -replaces and the rendered bullet is unchanged. diff --git a/.changeset/drift-detect-multi-value-base-type-mismatch.md b/.changeset/drift-detect-multi-value-base-type-mismatch.md deleted file mode 100644 index bf8b6eef9f..0000000000 --- a/.changeset/drift-detect-multi-value-base-type-mismatch.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -Report a multi-value field left on a stale `varchar`/`text` column, instead of -letting it silently corrupt every array written to it - -A field that gains `multiple: true` materialises as a `json` column on a fresh -database, but `initObjects` is additive-only: on a database created while the -field was single-value, nothing is missing, so nothing is added and the old -`varchar`/`text` column is kept forever. The write path stringifies the array -for a json field on every non-SQLite dialect; the read path relies on the -driver's column-type-based decoding, which a stale textual column defeats. The -array goes in as the literal `["id1","id2"]` and comes back as a **string** — -so a hook copying the value into a child record's single-lookup column writes -that whole string as one id. User-filed production report, repaired by hand on a -live database. - -Until now the schema-drift detector said **nothing** about it. Measured on the -pre-fix tree against live Postgres 16.13 and MySQL 8.0.46: after the metadata -change and a reboot, `detectManagedDrift()` returned `[]` and the boot logged -zero `[schema-drift]` lines, while the very next write stored -`["user_A","user_B"]` into a `character varying(255)` column and read it back -with `typeof === 'string'`. The action vocabulary had no "the base type is -wrong" entry at all — only `relax`/`tighten_not_null`, `widen`/`narrow_varchar`, -`drop_column`, `drop_column_default` and the index ops. - -The divergence is now **detected and reported**, naming the table, the column, -the declared type, the physical type and the exact statement an operator runs by -hand — dialect-correct, and executed against both live servers by the suite -rather than merely printed. ObjectStack does **not** change the column: an -`ALTER TABLE … TYPE json USING …` over existing rows with an index drop and -rebuild is a destructive migration over shipped data, and whether the platform -should perform it is a separate, open decision. The new `manual_column_type_change` -op deliberately has no reconciler arm; `applyMigrationEntries` reports it as -skipped, which is the intended contract while that decision is open. - -Reported at severity `error` and category **`needs_confirm`**, and the category -is load-bearing rather than cosmetic. Every database this finding describes is -already serving — that is the premise of the report — and the artifact-pinned -boot gate refuses a boot for `category === 'destructive'` and nothing else -(`severity` it never reads). Measured both ways: a `destructive` entry returns -`ok=false` from that gate, this entry returns `ok=true`. Spelling it -`destructive` would have turned every affected deployment into a crash-loop on -its next restart — the report of the corruption becoming the outage. - -SQLite is deliberately excluded, and the exclusion is a measurement rather than a -scoping convenience: the same stale column reads back as a real `['x','y']` -array there, because SQLite's read path `JSON.parse`s regardless of what the -column calls itself. There is no corruption to report, and reporting it anyway -would put a permanent `error` finding on every long-lived SQLite development -database. A stale `integer`/`timestamp` column is excluded for the mirror-image -reason — the server already refuses that write loudly, so there is no silence to -break. - -Also fixed, same defect class: a multi-value field that *also* declared -`maxLength` used to produce `narrow_varchar` at severity `error`, category -**destructive** on both enforcing dialects — a finding that refuses the -artifact-pinned boot and invites `os migrate apply --allow-destructive` to -rewrite the column to `varchar(50)`, the exact opposite of the repair it needs. -`createColumn` returns at its `multiple` branch before `maxLength` is ever read, -so the emitter never asks for that width; the differ no longer does either. The -single-value width branch is untouched and pinned as untouched. diff --git a/.changeset/drift-multi-value-column-names-remedy-command.md b/.changeset/drift-multi-value-column-names-remedy-command.md deleted file mode 100644 index d1ca89503b..0000000000 --- a/.changeset/drift-multi-value-column-names-remedy-command.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -The stale multi-value column warning now names `os migrate multi-value-columns`, -instead of telling operators ObjectStack will never fix the column - -The finding that reports a multi-value field left on a stale `varchar`/`text` -column opened its remedy with **"ObjectStack will NOT change this column for -you. Migrate it by hand"** and then printed raw SQL. That was true when it was -written and became false the moment `os migrate multi-value-columns` shipped: -there is now an operator-run command that does exactly this, with a dry run as -the default, a confirmation prompt, and a post-run re-detection that exits -non-zero if the finding has not cleared. Operators were being sent to hand-write -DDL on a production table while the safer route sat one command away, unnamed. - -The message now leads with the command and keeps the hand-run statement after it -for anyone without the CLI. Both surfaces an operator meets this on pick the -change up, because both print `message` verbatim: the boot warning -(`[schema-drift] …` on every restart) and `os migrate plan`. - -What has **not** changed is what the finding gates. It stays `severity: 'error'`, -`category: 'needs_confirm'` — the artifact boot gate refuses a boot on -`category === 'destructive'` and on nothing else, and every database this finding -describes is already serving, so making the report louder must never be the thing -that stops one from starting. No load-time or write-time refusal was added; the -platform still never migrates the column on its own, per the ruling that it warns -and ships an explicit operator-run migration rather than altering a customer's -production table unattended. - -The dialect-specific statement stays embedded **verbatim**, which is a contract -rather than formatting: a `ManagedDriftEntry` carries no dialect, so the CLI -command recovers one by testing which dialect's statement the message contains. -That coupling is now pinned from the emitting side as well as the consuming one. diff --git a/.changeset/drift-unbounded-text-column-over-stale-varchar.md b/.changeset/drift-unbounded-text-column-over-stale-varchar.md deleted file mode 100644 index 6807b4bf08..0000000000 --- a/.changeset/drift-unbounded-text-column-over-stale-varchar.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -Report an **unbounded** text-family field left on a pre-existing `varchar` -column, instead of leaving the operator with a refused write and no diagnostic - -After #11875/#12119 a **newly created** `signature` / `qrcode` column is TEXT and -holds a data URI correctly. `initObjects` is additive-only, so on a database -created by an earlier release nothing is missing, nothing is added, and the old -`varchar(255)` column is kept forever — the boundary #12119's own changeset -states in as many words. What was not stated is what the drift reporter did -about it, and the answer was **nothing**. - -The varchar differ's entire branch required `declaredMaxLength !== undefined`, so -on a pre-existing table it split the text family by whether its author had -written a number: - -``` -Field.signature({ maxLength: 4096 }) over varchar(255) -> widen_varchar reported -Field.signature() — no bound over varchar(255) -> (nothing) silent -``` - -The second row is the common case. Measured on the pre-fix tree, one -`diffManagedTable` call per type on dialect `postgres` against a `varchar(255)` -column: `text` / `textarea` / `html` / `markdown` / `richtext` / `code` / -`signature` / `qrcode` with no `maxLength` each returned **zero** entries, while -`{ type: 'signature', maxLength: 4096 }` over the same column returned exactly -one `widen_varchar` in the same run — so the differ was working and this shape -was simply invisible to it. An upgrading deployment therefore saw no change and -no diagnostic, while the server kept refusing the same write; and the refusal is -a poor substitute for a report, because the live probe behind objectql's -`driver-fault-redaction.ts` measured Postgres's `22001` as identifier-only and -naming the **type** rather than the column (`value too long for type character -varying(255)`). - -The divergence is now **detected and reported** under a new report-only -`manual_widen_varchar_to_text` op, naming the declared type, the physical width, -the consequence, and both operator routes. Same `declared ≠ enforced` shape as -the #11374 / #11431 / #11875 family, closed one door further along — at the -migration seam rather than the authoring or write seam. - -**Nothing is migrated for you, and nothing new is refused.** There is no -reconciler arm: `os migrate apply` reports the entry as skipped, exactly as it -does for `manual_column_type_change`. The entry is `category: 'needs_confirm'`, -so the artifact-pinned boot gate — which refuses a boot for `destructive` and -nothing else — is unaffected: a deployment that merely refuses over-long values -must not become a crash-loop on its next restart. Dev auto-reconcile takes -`safe` only, so it never applies this unattended either. SQLite is excluded: it -enforces no declared width, so there is no divergence to report. - -`manual_widen_varchar_to_text` is a **distinct** op rather than a second use of -`manual_column_type_change`, for a measured reason: `os migrate -multi-value-columns` selects its entire population by -`op.type === 'manual_column_type_change'` and recovers the dialect by matching -the message against `manualJsonConversionSql`, so sharing the op would hand this -finding to a command whose remedy makes the column `json` — and, the message -carrying no json statement, have it refused as `remedy_not_recognized` on every -run. - -Graded `minor` rather than `patch` on two counts, matching the sibling drift-op -addition that shipped for #11535: `detectManagedDrift` emits a finding on -existing deployments where it previously emitted none (visible in `os migrate -plan`, in `os migrate apply`'s skipped count and in the boot-time -`[schema-drift]` warn), and the exported `DriftOp` union gains a member, which is -additive for producers but widens a type any consumer switching exhaustively -over it must account for. Nothing is removed, renamed or newly rejected, so it is -not a breaking change. diff --git a/.changeset/driver-own-key-undefined-normalisation.md b/.changeset/driver-own-key-undefined-normalisation.md deleted file mode 100644 index 021fdf3658..0000000000 --- a/.changeset/driver-own-key-undefined-normalisation.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/driver-memory": minor -"@objectstack/driver-mongodb": minor ---- - -fix(drivers): a declared field written as an explicit `undefined` is indistinguishable from one never written (#9276) - -A row has exactly two states to say about a field, each with a defined meaning: -**the key is absent** ("no value was ever written") or **the key holds a -value**. An own key holding `undefined` is neither. Only a JS-backed driver can -emit it — a SQL NULL arrives as `null`, which is a value — and every consumer -downstream had to invent a reading of it. Measured on `origin/main`, they did -not agree: `has(record.f)` on the real `@objectstack/formula` CEL engine reads -it as ABSENT, `materializeDeclaredFields` reads it as ABSENT by documented -design, and a bare `f in row` reads it as PRESENT. - -Both JS-backed drivers were measured separately, and they did **not** match: - -- **`driver-memory`** preserved the own key holding `undefined` through - `create` and handed it back from `find`. Its own projection path and its own - matcher already read the shape as absent (`projectFields` skips `undefined` - values, `{f: {$exists: true}}` excluded it, `{f: {$null: true}}` included it) - — so the returned row was the only surface in the driver still claiming the - key was present, and the same stored row answered `'f' in row` differently - depending on whether a projection was requested. -- **`driver-mongodb`** SPLIT. `create()` returns the object it built in - process, so the field came back as an own key holding `undefined`; but the - MongoClient default is `ignoreUndefined: false` and this driver sets no - override, so BSON stored `null` for that same field and a subsequent `find()` - answered `null` — a value. One write, two answers, from one driver. - -Both drivers now drop own keys holding `undefined` on the way into storage, so -a declared field written as `undefined` and one never written are the same row: -deep-equal, same own keys, same answer to every presence test. `null` is -untouched and stays a value. - -Fixed at the producer rather than at each consumer: converging one consumer -resolves one seam, but the next consumer that reasons about key presence -re-acquires the problem. - -**Behaviour that changes, precisely.** What these two packages RETURN for one -input class, and what `driver-mongodb` STORES for it. A caller passing an -explicitly-`undefined` property to `create`/`bulkCreate`/`update`/`updateMany` -(or seeding `initialData`) no longer sees that key in the returned row, and no -`null` is written for it in MongoDB. `undefined` does not survive JSON, so this -shape cannot arrive over the wire — reaching it requires in-process code. - -**What does NOT change.** No accept set moves: no schema, refine, validator or -public type is touched, nothing that parsed before is refused now, and no -exported name is added, removed or moved. Filter results are unchanged in both -drivers — measured identical before and after for `$null` / `$exists` / -equality on `driver-memory`, and on `driver-mongodb` `$null: true` lowers to -`$eq: null` and `$null: false` to `$ne: null`, which MongoDB matches -identically against a missing field and a stored `null`. - -Scope on `driver-mongodb` is the INSERT doors and the values returned. -`$set`-shaped patches are deliberately untouched: changing them would answer -"what does a patch carrying `undefined` mean — clear the field, or leave the -prior value standing" which is a storage-contract question, not this repair's -to settle. On `driver-memory` the normalisation is applied POST-merge for the -same reason — it keeps today's answer (every measured consumer read the merged -own-key-`undefined` as "absent", and the row now says absent outright) rather -than silently turning such a patch into a no-op. diff --git a/.changeset/driver-sql-aggregate-backend-fault-envelope.md b/.changeset/driver-sql-aggregate-backend-fault-envelope.md deleted file mode 100644 index 61b416782f..0000000000 --- a/.changeset/driver-sql-aggregate-backend-fault-envelope.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): `aggregate()` joins the enveloped read exits — a dialect error it -cannot attribute now leaves as `DATABASE_ERROR` / 500 instead of raw (#11455) - -`SqlDriver.aggregate()` executed its statement **bare**. Every dialect error the -backend raised left the driver as the backend's own error object: a `code` from -the backend's vocabulary, **no `status`** at all, and a message opening with the -compiled statement. `find()` and `count()` have carried the terminal ADR-0112 -envelope since #8931; this third read door was simply never given it. - -Measured on live PostgreSQL 16.13. The driver maps a `boolean` field to a real PG -`boolean` column and `SQL_AGGREGATE_FUNCTIONS` lowers the arithmetic aggregates to -a bare function name with no cast, so an ordinary analytics shape — a rate measure -over a flag column — reached the server as `avg("flag")`: - -``` -sum(flag) => THREW code=42883 status=undefined - msg=select sum("flag") as "n" from "…" - function sum(boolean) does not exist -``` - -A raw `42883` is on no list `@objectstack/rest` reads, so with `status` undefined -a caller-shaped mistake was logged as an **unhandled server fault**, and the -statement's shape travelled to the caller with it. - -`aggregate()` now composes the same `backendStatementFaultError` its two siblings -do: `DATABASE_ERROR` / 500, asserting exactly one thing — *the backend would not -run this statement* — with the dialect's own diagnostic written to the **server -log** rather than the caller's message, and the original error kept as a -non-enumerable `cause` so `isMissingTableError` and every other cause-following -predicate stay truthful. - -**No new error code.** ADR-0112 D3/D4 closed the `StandardErrorCode` vocabulary, -and D2's 2026-08-18 amendment retired three members on the reasoning that an -unreachable-but-declared code teaches a branch that can never fire. The code here -is the catalogued member the sibling read exits already answer with. - -**This is the envelope half only, and it decides no contract.** Whether the -platform should *answer a number* for an arithmetic aggregate over a boolean (by -casting in the lowering) or *refuse* is #11152's question, and #11249's for -`min`/`max`. Nothing here pre-empts it: the envelope is raised from the **exit**, -not from recognising `42883` or any wording, so it holds whichever way that card -is ruled — and the three dialects' arithmetic answers are deliberately left -unpinned (measured 2026-08-24: SQLite and MySQL's `tinyint(1)` both answer, -Postgres refuses). - -Unchanged, and pinned as controls: the precise refusals this door already -composed — an undeclared function (`INVALID_QUERY` / 400, #5907), a -`count_distinct` with no `field` (`INVALID_QUERY` / 400, #6409), a -per-aggregation `filter` (`NOT_IMPLEMENTED` / 501, #10576) — are all raised while -the statement is *built*, upstream of the guarded execution, so none can be buried -under the generic envelope. The accept set does not move: every condition that now -takes the envelope failed before this change and fails after it. diff --git a/.changeset/driver-sql-bounded-string-spec-parity.md b/.changeset/driver-sql-bounded-string-spec-parity.md deleted file mode 100644 index ba335d5c9c..0000000000 --- a/.changeset/driver-sql-bounded-string-spec-parity.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -chore(driver-sql): pin the varchar-sizing type switch against the spec's `BOUNDED_STRING_FIELD_TYPES`, so the two lists cannot drift apart silently (#12017) - -`packages/spec` decides which field types may DECLARE a `maxLength` -(`BOUNDED_STRING_FIELD_TYPES`, which `FieldSchema` and objectql's -record-validator both read since #11989/#11875). `driver-sql`'s -`varcharColumnChars` / `createColumn` switch decides which types get a column -SIZED from that declaration. The two are related by reasoning and nothing -asserted the relationship — so a type admitted into the spec's set without a -matching hand edit to the driver's switch falls to the catch-all -`table.string(name)` at knex's varchar(255): the author declares -`maxLength: 2000`, the platform formally accepts the declaration, and the -column refuses at 255. That is #11431's defect re-entering through a different -door. #12119 is the proof it is reachable — admitting `signature`/`qrcode` -required a hand edit to this switch that nothing would have caught if it had -been forgotten. - -⛔ No divergence existed: the lists were measured and agree. This adds the -missing guard, and changes no runtime code. - -The pin asserts set EQUALITY over the spec's `FieldType` vocabulary — the types -the switch sizes from a declared `maxLength` are exactly the types the spec -permits to declare one — and identifies each type's branch by probing the -driver's own dispatch, so no copy of either list is added. `'string'` is pinned -separately as the switch's untyped default (`field?.type || 'string'`, knex's -builder name, not a spec `FieldType`), which is why the equality is scoped to -the declared vocabulary. - -Grade: `patch`, argued rather than defaulted. Not `minor` — no new public API, -no widened accept-set, no behaviour change of any kind; the emitted DDL is -identical. Not `skip-changeset` either, though this PR ships only a test: the -package's published CONTRACT (a bounded-string field gets a column that honours -its declared bound) becomes a checked invariant here, and the CHANGELOG line is -the record a future reader needs when the guard goes red. diff --git a/.changeset/driver-sql-introspect-fk-schema-scope.md b/.changeset/driver-sql-introspect-fk-schema-scope.md deleted file mode 100644 index 5439ed1832..0000000000 --- a/.changeset/driver-sql-introspect-fk-schema-scope.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): scope the Postgres `introspectForeignKeys` catalog read to the session's own schemas (#11201) - -The Postgres arm queried `information_schema.table_constraints` with -`tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?` and **no `table_schema` -predicate at all**. Those views span every schema the session has privilege on, -independently of `search_path`, so a table name that exists in more than one schema had -all of their foreign keys merged into a single answer — including foreign keys from -schemas the session can never reach unqualified. - -That is a wrong answer rather than a missing one, and it is consumed as fact: -`introspectSchema` hangs the result on the table it just listed, and from there it reaches -federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift -comparison. A phantom foreign key makes a drafted federated object reference a table it -does not reference. - -The fix is the pin the rest of the family already carries — -`AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed exactly as -`introspectUniqueConstraints` spells it, which in turn follows `introspectSchema`'s own -table listing. `introspectForeignKeys` was the last unscoped introspection arm; the two -`pg_index`-based arms (`introspectIndexes`, `introspectPrimaryKeys`) reach the same scoping -from the other side by resolving the name to an OID through `regclass`. No interface shape -and no accepted input changes: a same-named table in another schema simply stops -contributing foreign keys it never should have contributed. - -Measured on a live PostgreSQL 16.13. The regression pin -(`sql-driver-11201-introspect-fk-schema-scope.test.ts`) builds the collision the repo's own -live-PG isolation (#9350, one schema per test file in one database) already makes routine: -two same-named tables in two schemas, each with a different foreign key. It first asserts -the pre-fix predicate really sees both constraints — so the interesting assertion, an -absence, cannot go green on a fixture that never collided — then requires the arm and -`introspectSchema` to return only the current schema's. Reverse-verified: with the -predicate reverted the pin fails with the neighbour's foreign key present in the answer. - -The MySQL arm of the same method was checked and is not affected: it already pins -`TABLE_SCHEMA = DATABASE()`. SQLite has no schemas. diff --git a/.changeset/driver-sql-keyed-text-maxlength.md b/.changeset/driver-sql-keyed-text-maxlength.md deleted file mode 100644 index a7339c01e6..0000000000 --- a/.changeset/driver-sql-keyed-text-maxlength.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -**Fix:** a text-family field that a declared index keys on is emitted as `varchar(maxLength)` instead of an unbounded `TEXT`, so the index MySQL previously refused can actually be created (#11374). - -`createColumn` mapped the whole text family (`text` / `textarea` / `html` / `markdown`) to an unbounded `TEXT`, ignoring the field's own declared `maxLength`. MySQL refuses a `TEXT`/`BLOB` column in a key without a prefix length, and the two halves of schema-sync fail *separately*: the `CREATE TABLE` succeeds, then `ALTER TABLE … ADD [UNIQUE] INDEX` fails with `ER_BLOB_KEY_WITHOUT_LENGTH`. The table therefore lands on disk **without the constraint it declared**, and the object stays registered-but-broken. Measured on a live MySQL 8.0.46: **36 of the 44 platform objects** failed schema-sync this way, so a stack whose `default` datasource is MySQL could not stand up its own schema — the dev-admin seed never landed and first sign-in returned `401 INVALID_EMAIL_OR_PASSWORD`. Honouring the declared bound takes that to **12**. - -**The bound is the field's own `maxLength` — nothing is invented.** `schema-drift.ts` already treated `varchar(field.maxLength)` as the expected physical shape of a bounded field (its `widen_varchar` / `narrow_varchar` ops say so in as many words); this is the emitter finally agreeing with the differ. On MySQL that removes a permanent destructive drift finding: `columnInfo()` reports `maxLength: 65535` for a `TEXT` column, so every bounded text field already reported `narrow_varchar` ("metadata caps at 32 chars but the column allows 65535") against a column the driver itself had created. - -**Scope, both halves load-bearing.** The bound is emitted only for a column some declared index **keys on** — a non-indexed `Field.text({ maxLength: 65000 })` stays `TEXT`, because `varchar(65000)` on utf8mb4 is 260000 bytes and would blow MySQL's 65535-byte row limit, turning a working table into an un-creatable one. And only where the bound is **usable as a key part**: `maxLength` absent, or wider than 768 characters (3072 index bytes ÷ 4 bytes per utf8mb4 character — measured: `varchar(768)` takes a unique index, `varchar(769)` is refused with `ER_TOO_LONG_KEY`), leaves the column `TEXT` and the index refused with a message naming the field and the declaration that fixes it. - -**⚠️ Graded `minor`, not `patch`: this changes declared behaviour on newly created tables.** A keyed bounded text column now enforces its declared length where the dialect enforces `varchar` (Postgres and MySQL), so a write longer than `maxLength` that previously landed in an unbounded `TEXT` is now refused — under `STRICT_TRANS_TABLES`, with `ER_DATA_TOO_LONG`. That is the declaration becoming enforced rather than a new restriction, and it is exactly what makes the column indexable, but it is a behaviour change and is named here as one. **Existing tables are unaffected**: schema-sync is additive and never rewrites a column that is already present. - -**A prefix index is deliberately NOT substituted for an unkeyable column.** For an ordinary index that would be a transparent access-path choice, but for a `UNIQUE` one it silently replaces the declared constraint with a stricter one — uniqueness of the *prefix*. Measured on MySQL 8.0.46 with `UNIQUE KEY (token(191))` and two distinct 200+ character tokens sharing their first 191 characters: the second insert was rejected with `ER_DUP_ENTRY` **even though the tokens differ**. On `sys_session.token` that is a valid sign-in refused as a duplicate. The refusal an operator can read is strictly better than a constraint that quietly means something else. diff --git a/.changeset/driver-sql-logger-receiver-detach.md b/.changeset/driver-sql-logger-receiver-detach.md deleted file mode 100644 index d97ac3d2b0..0000000000 --- a/.changeset/driver-sql-logger-receiver-detach.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): keep the logger's receiver at the nine detach-then-call sites — a class-based host logger no longer turns a durability warning into a `TypeError` (#12792) - -Nine sites in `sql-driver.ts` picked a log channel by **extracting** the method before -calling it — eight on the durability channel and one on `info`: - -```ts -(this.logger.error ?? this.logger.warn)(msg, meta); // 8 sites -(this.logger.info ?? this.logger.warn)(msg); // 1 site -``` - -`a.b` in *call position* passes `a` as the receiver; `(a.b ?? c.d)(…)` evaluates to the -bare function first, so the call runs with `this === undefined`. A plain-closure logger -does not read `this` and survives it — which is why no suite ever went red, since this -class's own default sink and every test double in the package are closures. -`@objectstack/core`'s `ObjectLogger` is a real class with prototype methods and no -constructor binding — `error`/`fatal` reach for `this.writeErrorLike`, -`debug`/`info`/`warn` for `this.write` — so a host that injects one got: - -``` -TypeError: Cannot read properties of undefined (reading 'writeErrorLike') - at error (packages/core/src/logger.ts:414:14) - at SqlDriver.syncDeclaredIndexes (packages/drivers/driver-sql/src/sql-driver.ts) -``` - -The asymmetry that makes it worth fixing rather than noting: these particular lines -report **durability degradation and schema drift** — the channel that exists to be loud -when a constraint the metadata claims is enforced is not. A throw there converts the one -signal into silence plus an unrelated crash, and where the site sits inside the -reconcile's own `try` the throw is swallowed and re-reported as -`dev auto-reconcile failed` — a reconcile that really happened, announced as a failure, -with the post-reconcile re-detect skipped so the next warning describes a state that is -no longer true. - -The eight `error ?? warn` sites now call `logDurabilityFailure()` — the property-access -helper this class already had, three lines from the docblock that explains it. The ninth -is `info ?? warn` and reports a reconcile that **succeeded**, so it keeps its level with -an in-place property-access spelling rather than being escalated onto the durability -channel; escalating a functional report to `error` is the over-application AGENTS.md -names as what makes `error` unreadable in the first place. - -**Why `patch`.** No export, signature, accepted input or rejected input changes, and no -message text changes. A host whose logger is a plain closure object sees byte-identical -behaviour — that shape worked before and is pinned unchanged. The one behaviour a -consumer could observe is a subclass that overrides the `protected` -`logDurabilityFailure`: eight more calls now route through its override. That method is -already this class's declared verb for the durability channel and the fallback semantics -at those sites are unchanged (`error` when the sink has one, else `warn`), so more calls -honouring the override is the documented intent rather than a break. - -Also measured and recorded rather than assumed: **nothing composes an `ObjectLogger` -into `SqlDriver` today**. The plugin's `onEnable` builds `new SqlDriver(config)` and -never passes the kernel's logger, the constructor reads no `logger` key, and every -`driver.logger = …` assignment in the repo is a test or a testkit; the one production -seam that *can* install one is `SqliteWasmDriver`'s constructor, inherited straight into -this class, and no caller passes it yet. So these were latent, not live — which decides -urgency, not whether: a call that runs with `this === undefined` is a defect whatever -today's wiring happens to tolerate. - -The regression pin (`logger-receiver-detach.test.ts`) uses **class-based** logger doubles -whose channels dispatch through `this`, drives the real reconcile and the real -declared-index sync against real SQLite, and adds a structural AST scan over -`sql-driver.ts` for all four detach spellings — including the two a single-line regex -cannot see, which is how this file's count was twice taken as a floor. diff --git a/.changeset/driver-sql-mysql-fk-introspection-ordinal-order.md b/.changeset/driver-sql-mysql-fk-introspection-ordinal-order.md deleted file mode 100644 index 82aae4191c..0000000000 --- a/.changeset/driver-sql-mysql-fk-introspection-ordinal-order.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): order the MySQL `introspectForeignKeys` read by the key ordinal (#11379) - -`SqlDriver.introspectForeignKeys`' MySQL arm read `information_schema.KEY_COLUMN_USAGE` -with no `ORDER BY`. `ORDINAL_POSITION` is the key ordinal and was selected by neither the -projection nor an order clause, so the row order of a composite foreign key's columns was -whatever the query plan happened to yield. - -That order is load-bearing. `IntrospectedForeignKey` is a flat per-column record with no -ordinal field, so a composite key is expressed as **ordered sibling rows** — `(x, y) -references p (a, b)` is `x -> p.a` then `y -> p.b`, and there is nothing for a consumer to -recover the position from if the rows arrive permuted. The Postgres arm pins this with -`ORDER BY … k.ord`; the MySQL arm was leaving it to the optimizer. - -This is a determinism fix rather than the repair of a wrong answer, and the measurement is -what distinguishes the two. On MySQL 8.0.46, a foreign key declared out of column sequence -— `foreign key (second_col, first_col) references ooo_parent (pa, pb)` — came back in key -order through this predicate with no `ORDER BY` at all. But on the same server, in the -same session, over the same view, the sibling `introspectPrimaryKeys` predicate -(`CONSTRAINT_NAME = 'PRIMARY'`) returned an out-of-sequence primary key in **column** -order — `carrier_code` at ordinal 2 ahead of `shipment_id` at ordinal 1. `KEY_COLUMN_USAGE` -therefore does not preserve the ordinal for free on this server: which of the two orders -you get is decided by the `WHERE` clause, and nothing declared that. The foreign-key -predicate was on the lucky side of a choice nobody made. - -Consumers that read composite foreign keys through `introspectSchema` — federated-object -codegen, the persisted `external_catalog` (ADR-0015), and schema-drift comparison — now get -the declared key order from MySQL by construction rather than by plan choice. diff --git a/.changeset/driver-sql-single-column-unique-introspection.md b/.changeset/driver-sql-single-column-unique-introspection.md deleted file mode 100644 index e7315e8ecf..0000000000 --- a/.changeset/driver-sql-single-column-unique-introspection.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202) - -`SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that -`introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms -disagreed about what that list meant. SQLite pushed a column only when the unique index -had exactly one column; the Postgres and MySQL arms returned **every member of every -composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres -claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not -make — while through SQLite it claimed neither. - -The divergence was latent rather than active until recently: the Postgres arm's query -selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method -carried until #11161 turned every execution into `[]`. Live Postgres had therefore never -once reported a unique constraint through this method. Repairing that query is what put -three dialects into conflict on live systems for the first time. - -Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to -**single-column uniqueness only**: a column is reported iff some unique constraint covers -that column and nothing else. A composite constraint's members are deliberately absent — -a per-column boolean is structurally unable to say "a and b are unique *together*", so -setting it on both members asserts something different and false. Representing composite -constraints is option B and waits for real demand; until it exists, an absent flag on a -composite member means "not single-column unique", never "no constraint". - -All three arms now normalise their rows to a `UniqueConstraintMember` and decide through -one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres -arm additionally selects `constraint_schema` and keys constraint identity on -`(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a -unique constraint after the table and column, so two same-named tables in two schemas -produce two different constraints under one name — keyed on the name alone they would -fuse into an apparent two-member constraint and drop a genuinely single-column unique -(the #11201 defect class, one method over). - -Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of -`PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports -`name: null`, which the arm used to push into a `string[]` as a literal `null` — it is -now discarded, while still counting toward the index's width so `(d, lower(e))` cannot -read as single-column; and the returned columns are de-duplicated, so a column carrying -both a `UNIQUE` clause and a hand-made unique index is named once. - -No interface shape and no accepted input changes, and `isUnique` is only ever *set* to -`true`, so a column that stops being flagged carries `undefined` exactly as an -unconstrained column always has. The one in-tree consumer is -`introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a -drafted field's `unique: true` — it is the direct beneficiary: composite members no -longer draft fields declaring a single-column uniqueness the database never enforced. - -Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the -live Postgres and MySQL cells are declared through the shared dialect matrix and run in -the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned -directly against each dialect's real row shape, so the Postgres and MySQL decision is -measurable without a provisioned server. Reverse-verified by ablation: with the width -filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the -end-to-end SQLite cell, and the `isUnique` fold. diff --git a/.changeset/driver-sql-stale-text-column-remedy.md b/.changeset/driver-sql-stale-text-column-remedy.md deleted file mode 100644 index c29e4d6566..0000000000 --- a/.changeset/driver-sql-stale-text-column-remedy.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): an unkeyable TEXT column whose field ALREADY declares a bound now names the real remedy (#12999) - -One message served two causes and was true of only one of them. - -`explainUnkeyableTextColumn` turns MySQL's `ER_BLOB_KEY_WITHOUT_LENGTH` / -`ER_TOO_LONG_KEY` index refusal into operator-readable advice. It rendered -every such refusal as *"the field declares no `maxLength` … declare -`maxLength` on the field(s)"*. That is correct at CREATE time. On the UPGRADE -path both halves are false: the additive sync adds columns and indexes and -deliberately never rewrites a column's type (#3728), so once a release adds a -bound to a previously unbounded keyed field (#12978 did exactly that for five -`sys_notification_*` objects), the field declares a perfectly usable -`maxLength` while the physical column is still TEXT. The index is refused -again on every boot and the message tells the operator to do the thing they -already did — in production, once per boot, which reads as the release that -shipped the fix being broken. - -**What changed.** A second branch, selected per column on a criterion that -needs both halves: the physical column is TEXT *and* `keyableTextLength` says -a fresh create would have emitted `varchar(n)` for the field's declared bound. -Both inputs were already in hand on the failure path — the `columnInfo()` read -this method already performs, and the driver's `managedObjectFields` -registration. That message names the column, the bound it already declares, -that re-declaring changes nothing, and the remedy that does apply: convert the -column to `varchar(n)` **by hand, with a backup taken first**, restating the -FULL column definition on MySQL — `MODIFY` does not repeat a `NOT NULL` and -silently drops a `DEFAULT` it does not restate — after which the next boot -creates the index. A composite key that mixes a stale column with a genuinely -unbounded one names both dispositions rather than sending the operator down -one route for both. - -**What deliberately did not change.** - -- The CREATE-path message is **byte-identical**, and is what a field that - really declares no usable bound still gets. A declared bound *wider* than a - utf8mb4 key part can hold (768 characters) is not a stale column either — a - fresh create emits TEXT for it too — so it keeps the CREATE message, whose - 768-character ceiling is the fact that operator needs. -- The refusal stays **loud and stays a failure**. The index genuinely was not - created and a declared uniqueness is genuinely unenforced; naming a better - remedy is not a reason to downgrade or silence that. -- The additive sync still does **not** rewrite the column itself. A widening - `ALTER … MODIFY` takes an exclusive metadata lock on the table, which makes - it a destructive, hard-to-roll-back action and a deliberate manual floor - rather than something a boot may decide to do. - -Diagnostic text only: no schema, DDL, wire or API surface moves. diff --git a/.changeset/driver-sql-update-stamp-precision.md b/.changeset/driver-sql-update-stamp-precision.md deleted file mode 100644 index 099f3e0a85..0000000000 --- a/.changeset/driver-sql-update-stamp-precision.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql): stamp `updated_at` at the audit column's own precision on MySQL, so an updated row stops reading as modified BEFORE it was created (#11224) - -`createAuditTimestampColumn` builds the audit columns on MySQL as `DATETIME(3)` -defaulted with `now(3)`, and its docblock says why in as many words -("`CURRENT_TIMESTAMP` has to carry matching precision for a `DATETIME(3)` -default", #3942). `updatedAtStamp()` — the value every UPDATE door writes into -that same column — was a bare `knex.fn.now()`, which compiles to an unqualified -`CURRENT_TIMESTAMP` that MySQL truncates to whole seconds. So the column was -created at millisecond precision on purpose and then written at second precision. - -Measured on live MySQL 8.0.46, against the exact schema the driver produces: - -``` - created_at updated_at delta -before CURRENT_TIMESTAMP 2026-08-23 10:22:36.799 2026-08-23 10:22:36.000 -799 ms -after CURRENT_TIMESTAMP(3) 2026-08-23 10:22:36.799 2026-08-23 10:22:36.802 +3 ms -``` - -Nothing errors. Three silent consequences, in ascending order of damage: - -1. **"Last modified" precedes "created".** Any consumer comparing the two — an - audit answer, a "modified since creation?" badge, a data-quality check — - reads a row that WAS modified as if it were not. -2. **A delta / incremental sync SKIPS the row.** A cursor held at millisecond - precision (`updated_at > cursor`) misses every row whose stamp was truncated - back below it. Measured: all six rows in the new suite's §2 were invisible to - their own cursor immediately after being updated. This is the same - silent-wrong-answer family as #11067 / #11176 / #11223, reached by a fourth - mechanism. -3. **Two updates in the same second are indistinguishable**, so an - `order by updated_at` over them is unstable exactly where it matters most. - -The fix is the expression #11176 had already derived and measured for the UPSERT -door: `now(3)` on MySQL, unchanged elsewhere. Every UPDATE door reads one helper -(`update`, `updateMany`, `rotatedUpdateById`), so all three move together. - -**Postgres and SQLite emit byte-identical SQL to before, and that is a -measurement rather than an assumption.** Postgres' `CURRENT_TIMESTAMP` is -`transaction_timestamp()` at microsecond precision against a `timestamptz` -column; SQLite's stamp is a JS ISO-8601 string that already carries millis. -Neither has anything to truncate. The new suite runs every cell on SQLite AND on -live Postgres AND on live MySQL, and its §5 pins which expression each dialect -gets — so a future "just add `(3)` everywhere" cannot satisfy the ordering -assertions while changing the SQL the other two dialects emit. In the baseline -run against the unfixed driver, the SQLite and Postgres cells were green (7/7 -each) and only the MySQL cell was red (6 of 7). - -**BREAKING**, narrowly, and the reason this is not a patch: the `protected` -`upsertUpdatedAtStamp()` that shipped in 17.2.0 with #11176 is **removed**. It -existed only to hold the precision-matched form for the upsert door without -changing the SQL every `update()` emits — a split that card made deliberately -because it had not measured the UPDATE door. This one measured it, so the pair -collapses back into the single `updatedAtStamp()`, which now carries the matched -precision for both doors. A subclass of `SqlDriver` that OVERRODE -`upsertUpdatedAtStamp()` would otherwise have kept compiling while silently -ceasing to be called, which is precisely the failure mode a release note has to -name out loud. Such a subclass should override `updatedAtStamp()` instead; the -two in-repo subclasses (`SqliteWasmDriver`, `TursoDriver`) override neither and -are unaffected. Nothing else was removed or renamed, no authored metadata -changes, and no public API moves — under this repo's launch-window convention -(breaking changes ship as `minor` while the stack versions in lockstep) `minor` -is the honest slot. - -Stored data is not rewritten. Rows updated before this change keep their -truncated `updated_at`; the ordering invariant holds from the next write onward. - - diff --git a/.changeset/element-form-lint-residue.md b/.changeset/element-form-lint-residue.md deleted file mode 100644 index 857779abc1..0000000000 --- a/.changeset/element-form-lint-residue.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249) - -The whole `element:form` element retired at element grain (ADR-0049 — no -renderer ever shipped for it; the #9220 shape one element over), so every -`ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant -page carries `fields` on it. The field-binding rule's job (resolve a field -NAME against the object) is not the question a retired key raises: an authored -key is already reported by name with the element-retirement prescription — -which names the live replacement, the object-bound `object-form` block — -through the #5068 props gate, and the binding entry would only add a second -finding about a key that no longer exists — the #5775/#6629 residue class the -package's own `component-field-specs-liveness` gate refuses. diff --git a/.changeset/element-form-retired.md b/.changeset/element-form-retired.md deleted file mode 100644 index 7c5c0ec9a7..0000000000 --- a/.changeset/element-form-retired.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the `element:form` element at element grain (#9249, 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). - -`element:form` never had a renderer or reader anywhere — the #9220 -(`element:filter`) shape one element over, recorded by that card's own verdict -sweep. Measured at retirement (objectstack `dd84ddd796`, objectui `3ece13e33`; -cloud per the origin card's two recorded readings at `5f1bf23f` and `a11458b`, -positive controls passing): objectui registers no renderer for it — its -`renderers/basic/elements.tsx` header deferred the element to "owning plugins" -that never materialized — and Studio's designer palette carries it as a -no-renderer `PALETTE_EXCLUSIONS` entry that names the live replacement ("no -renderer — use the object-bound `object-form` block"). The 2026-06 -page-liveness audit already recorded it rendering "Unknown component type". So -the full form contract `ElementFormPropsSchema` declared — `object`, `fields`, -`mode`, `submitLabel`, `onSubmit` (CEL), `aria` — was a capability claim -nothing kept. - -**What is refused:** every authored `element:form` key. All six are -`retiredKey()` tombstones, so authoring one is a `tsc` error and a parse error -carrying the prescription (fully-qualified key, why the element is dead, the -replacement, the `os migrate meta` pointer), dispatched through the KEPT -`ComponentPropsMap` row — deleting the row would demote the type to an -unregistered custom string the #5068 props gate deliberately skips. - -**What stays accepted:** a bare `element:form` node with empty `properties` -(the migrated shape — the open `type` union accepts any string, and deleting -authored page nodes is a layout decision a mechanical conversion must not -make). It renders nothing, exactly as it always did. - -The retirement kit: - -- `retiredKey()` tombstones for all six keys at the schema - (`packages/spec/src/ui/component.zod.ts`); the `PageComponentType` enum - drops the value (de-advertisement — the open string arm still accepts it) -- ADR-0087 registration: six retired-key entries - `ui/ElementFormProps:{aria,fields,mode,object,onSubmit,submitLabel}` and the - D2 conversion `element-form-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips all six keys from authored - `element:form` blocks (pure lossless deletes; none ever had an effect to - lose) and leaves the bare node -- pin tests (`component.test.ts` — refusal pins assert the prescription per - key; a positive pin parses the bare migrated node clean and asserts nothing - materializes; the kept-map-row pin flips from parse to refusal) -- `packages/lint`'s `COMPONENT_FIELD_SPECS` entry drops (its own changeset); - the component-translation `submitLabel` describes stop naming `element:form` - as carrier (the orphaned-key decision is #10926, out of this card's scope) -- generated baselines/docs follow the schema (authorable surface/defaults, - spec-changes, upgrade guide, reference docs) -- objectui's `elements.tsx` comment and Studio palette-exclusion lines are - cross-repo and already queued under objectui#4935 - -## FROM → TO - -```ts -// before — parsed green; nothing anywhere rendered it, so the page showed -// "Unknown component type" where the author expected a form -{ - type: 'element:form', - properties: { - object: 'lead', - fields: ['name', 'email'], - mode: 'create', - submitLabel: 'Create Lead', - }, -} - -// after — use the object-bound `object-form` block (#7751): rendered, -// designer-publishable, same intent (`objectName`, `fields`, `mode`, -// `submitText`) -{ - type: 'object-form', - properties: { - objectName: 'lead', - fields: ['name', 'email'], - mode: 'create', - submitText: 'Create Lead', - }, -} -``` diff --git a/.changeset/eleven-introspect-contract.md b/.changeset/eleven-introspect-contract.md deleted file mode 100644 index 53f6e8c0ac..0000000000 --- a/.changeset/eleven-introspect-contract.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The engine-registration road into datasource introspection now meets the compiler (#11493, extending the #11123 ruling from the `DatasourceDriverHandle` seam): `IDataDriver` gains an optional `introspectSchema?(): Promise` member, and `IDataEngine` gains an optional `introspectDatasource?(datasource: string): Promise` member. Both are typed with the spec's one introspection shape (`IntrospectedSchema`, `@objectstack/spec/contracts`). Drivers and engines without introspection stay conformant — the members are optional — while a driver that DOES implement `introspectSchema` with a mis-shaped result (a column flag spelled `isPrimary`, a bare `{ tables }` with no `dialect`/`introspectedAt`) now fails compile at the offending field instead of surfacing at runtime as a federated table whose records cannot be located. diff --git a/.changeset/eleven-introspect-engine.md b/.changeset/eleven-introspect-engine.md deleted file mode 100644 index c3db78e862..0000000000 --- a/.changeset/eleven-introspect-engine.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -`ObjectQL.introspectDatasource()` declares its real return type — the spec's `IntrospectedSchema` (the new `IDataEngine.introspectDatasource?` contract member) — instead of an untyped `Promise`, and the driver lookup inside it drops its `as any` now that `IDataDriver` declares `introspectSchema?`. Type-level only; runtime behaviour is byte-identical (#11493). diff --git a/.changeset/eleven-introspect-plugin.md b/.changeset/eleven-introspect-plugin.md deleted file mode 100644 index 3a41edfd34..0000000000 --- a/.changeset/eleven-introspect-plugin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -`ExternalDatasourceServicePlugin` types the `'data'` service with the real engine contract (`IDataEngine`, `@objectstack/spec/contracts`) and deletes its private structural `DataEngineLike` re-declaration — the workaround the untyped `IDataEngine.introspectDatasource()` forced (#11493). The introspection fallback branch now probes `getDriverByName?` (the registry member the contract declares) instead of `getDatasourceDriver?`, a spelling no engine in either repository ever had, so the degradation path is reachable for the first time. diff --git a/.changeset/eleven-maps-move-home.md b/.changeset/eleven-maps-move-home.md deleted file mode 100644 index 569dbbbd9d..0000000000 --- a/.changeset/eleven-maps-move-home.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": patch ---- - -Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须 -schema-free」) for `@objectstack/core`'s plural→singular store-key fold. - -`@objectstack/spec`: the `defineStack()` manifest-collection vocabulary -(`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`, -`singularToPlural`) moved to a schema-free module and is now ALSO exported -from the sanctioned schema-free entry `@objectstack/spec/meta-spelling` -(widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared` -keeps the same four symbols as re-exports — no consumer-visible removal. The -manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts -(#8424). - -`@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves -from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so -browser consumers of `@objectstack/core` (every `@objectstack/client` bundle) -no longer link the zod schema closure through the store-key fold. diff --git a/.changeset/engine-findone-predicate-guard.md b/.changeset/engine-findone-predicate-guard.md deleted file mode 100644 index bf712f1cb2..0000000000 --- a/.changeset/engine-findone-predicate-guard.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor ---- - -feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957) - -`ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record -would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that -call. Every in-memory test double in the repo instead read an absent filter as -"match everything" and answered happily, so a production call site that violates -#4419 read as *working* under every unit suite and only failed on a real engine. - -That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the -bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a -real engine that throws, the `catch` read the refusal as "users exist", and the -declared first-run bypass became permanently inert on real deployments — while a -641-line unit matrix over the double stayed green, including a case named -"bootstrap: the very first signup is admitted" (#11767). - -New public API, mirroring the two write-side dispatch predicates -(`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the -implementation lives in `@objectstack/metadata-core` so that packages -`@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql` -re-exports every symbol: - -- `assertEngineFindOnePredicate(object, query)` — the line a fake engine's - `findOne` opens with; throws the engine's own message, object name included. -- `resolveEngineFindOnePredicate(object, query)` — the same decision without the - throw, for a double that wants to classify. -- `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an - assertion pins the producer's wording rather than a paraphrase. -- `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven - against the REAL engine by - `packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate - cannot drift from `engine.ts` unnoticed. - -Nothing is removed and no existing behaviour changes: the engine's own guard is -untouched, and this publishes the decision it already makes so a double can -import it instead of re-deriving it. diff --git a/.changeset/entry-nameability-generalized-closure.md b/.changeset/entry-nameability-generalized-closure.md deleted file mode 100644 index 12b67f253f..0000000000 --- a/.changeset/entry-nameability-generalized-closure.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Close the entry-nameability invariant on the four remaining public entries that leaked it (#12414). Generalizing #11350/#11709's `defineStack`-only probe to every public entry found the class was never fully closed: seven (entry, type) pairs across four entries still had a `define*` factory's return type expand to mention a type the entry itself did not re-export — `Book`, `FormField`, `NavigationItem` from the root `@objectstack/spec` entry, `UnknownAuthoringKeyFinding` from `@objectstack/spec/kernel`, `FilterCondition` and `StateNodeConfig` from `@objectstack/spec/ai`, and `FilterCondition` from `@objectstack/spec/ui`. Each is repaired the same way as the prior two rounds: one re-export line on the leaking entry, from the module that already declares the type as public on its own subpath. A consumer writing an un-annotated `export const x = defineBook(...)` (or the `/ui`, `/ai`, `/kernel` equivalent) previously hit TS2883 naming an unaddressable hash-named internal dist chunk and could not build; all seven now declaration-emit cleanly with no annotation required. No runtime change. `packages/spec/entry-nameability.baseline.json`, the shrink-only ledger `check:entry-nameability` (#11986) reads, is now empty — the class it was tracking is closed. diff --git a/.changeset/envelope-audit-shared-home.md b/.changeset/envelope-audit-shared-home.md deleted file mode 100644 index 573787c85a..0000000000 --- a/.changeset/envelope-audit-shared-home.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/cloud-connection': patch ---- - -The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. diff --git a/.changeset/external-validation-checkonboot-enforced.md b/.changeset/external-validation-checkonboot-enforced.md deleted file mode 100644 index a6e041a788..0000000000 --- a/.changeset/external-validation-checkonboot-enforced.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): honour `datasource.external.validation.checkOnBoot` in the boot validation sweep (#13037) - -`checkOnBoot` has been declared on `DatasourceSchema` — with `.default(true)` — -since the `external.validation` block was written, and **nothing read it**. Its -two block-mates are read (`onMismatch` by `resolveOnMismatch()`, -`checkIntervalMs` by `scheduleDriftChecks()`), which is what made the gap legible -rather than a whole-block miss: `ExternalValidationPlugin.start` hooked -`kernel:ready` and called `runValidation(ctx)` with no condition on it. - -So an author who wrote `validation: { checkOnBoot: false }` and left `onMismatch` -at its default got the boot sweep anyway, and a measured mismatch threw -`ExternalSchemaMismatchError` and **aborted boot** — the exact outcome the key -reads as opting out of. The `.default(true)` made it worse than an ignored key: -the knob is materialized into every parse output, so a dead setting is -byte-identical to an honoured one in stored and serialized datasources, and -neither an author, an AI author, nor someone reading the metadata store could -tell which one they had. - -Maintainer ruling 2026-08-29 — ADR-0049 disposition **enforce, not remove**: - -- `checkOnBoot: false` ⇒ that datasource is skipped by the `kernel:ready` sweep. - No `onMismatch` policy is applied to its rows, so a measured mismatch on it can - no longer abort startup; its unreachable-remote rows raise no boot warning; and - its objects are not counted in the all-clear. The skip is logged once, naming - the datasources and stating that the verdict beside it covers the remaining - ones only. -- `checkOnBoot: true` or absent ⇒ today's behaviour, unchanged — a measured - mismatch still throws `ExternalSchemaMismatchError` and aborts boot under the - default `onMismatch: 'fail'`. - -The gate is **per datasource**, because the sweep is whole-farm and the key is -per-source: in one boot, an opted-out datasource does not suppress another -datasource's abort. Every uncertainty resolves towards running the check — an -absent key, an unparsed or legacy stored row, a managed datasource with no -`external` block, and a definition the metadata service could not read are all -validated, never inferred to have opted out. - -**Scope, pinned at the ruling: the boot step only.** `scheduleDriftChecks()` and -its `external.validation.checkIntervalMs` read point stay independent — a -datasource that opts out of the boot check keeps whatever background drift -checking it armed. The two keys answer different questions ("gate my startup on -this" versus "watch this while I run"), and both the code comment and a test hold -that boundary. - -Not a contract-face change: no schema, no key, and no accepted spelling moves. -`checkonboot` and `validateonboot` remain what they already were — entries in the -`strictObject` rejection table that refuse the misspelling and prescribe -`checkOnBoot` — so `checkOnBoot` is the single authorable spelling and the gate -has a single read point. diff --git a/.changeset/field-conditional-required-ledger-dead.md b/.changeset/field-conditional-required-ledger-dead.md deleted file mode 100644 index efb6c129f4..0000000000 --- a/.changeset/field-conditional-required-ledger-dead.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: re-classify `field.conditionalRequired` `live` → `dead` (#13043) - -Both halves of the entry's evidence were falsified, not just its citation. It -claimed a `FieldSchema .transform` lowering `conditionalRequired` → `requiredWhen` -that drops the alias: `packages/spec/src/data/field.zod.ts` has **zero** -`.transform` calls — its only two occurrences of that token are comments recording -that `.overwrite()` was chosen instead (#6926). And its note leaned on -`packages/objectql/src/validation/rule-validator.ts` keeping a -`requiredWhen ?? conditionalRequired` fallback "on purpose"; #3903 retired that -fallback, because every path that hands the validator a stored field definition now -replays the ADR-0087 conversion chain at rehydration, so a pre-17 row arrives with -the alias already lowered. The absence is pinned by that module's own test ("does -NOT read the retired conditionalRequired alias — PD #12, no dialect fallback"). - -The alias and its lowering were removed together in protocol 17 (#3855, PR #3883, -landed 2026-07-28); the key has been a `retiredKey` tombstone ever since. - -Data-only: no schema, no runtime, no authoring-surface change — authoring -`conditionalRequired` already failed `tsc` and the parse before this, and still -does. The row STAYS: per the `rls.priority` precedent a `retiredKey()` tombstone -keeps the key in the walked shape, so deleting the row would report UNCLASSIFIED. -`liveness/` is in this package's `files` array, so these ledgers ship in the npm -tarball and this is published data. - -This was the last `path:NNN` citation in any ledger. Retiring it takes the gate's -line-citation counter to zero and closes #13003's symbol-anchor adoption worklist, -which is why the same change deletes the two non-vacuity floors that guarded that -population (and their guard comments) exactly as those comments instructed. diff --git a/.changeset/field-deletebehavior-reference-only.md b/.changeset/field-deletebehavior-reference-only.md deleted file mode 100644 index c4936c3e5a..0000000000 --- a/.changeset/field-deletebehavior-reference-only.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): materialize `deleteBehavior` only on reference field types (#9784) - -`FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default -onto non-reference field types (`text` / `datetime` / `number` / every other -non-relational type, `user` included). The key has no meaning there — the -engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` / -`lookup` fields carrying a `reference` — yet the materialized default shipped -in every built app artifact, where a parse-time default becomes an apparent -explicit declaration downstream (the #4447 shadowing mechanism) and reads as -meaningful to AI authors browsing the artifact. - -What changes and what does not: - -- **Bare non-reference fields** parse to output that **omits** `deleteBehavior` - (previously: `deleteBehavior: 'set_null'` materialized on every type). Built - artifacts thin accordingly — measured on the showcase app: 210 fields, the - key drops from 206 fields to 16. -- **`lookup` and `tree`** keep materializing `set_null` byte-identically, at - shape position. -- **`master_detail`** keeps omitting it (the #9689 idempotent-materialization - ruling, unchanged). -- **The accept-set is untouched**: an authored `deleteBehavior` on any field - type parses exactly as before and round-trips verbatim, so artifacts built - by earlier versions (which carry the materialized key on every field) remain - fully legal inputs. `parse(parse(x))` holds across the boundary. - -No authored metadata needs any change: no key is removed, renamed or -re-shaped, and no authoring spelling that parsed before is refused now. diff --git a/.changeset/field-designer-master-detail-delete-behavior-options.md b/.changeset/field-designer-master-detail-delete-behavior-options.md deleted file mode 100644 index 6c89039f91..0000000000 --- a/.changeset/field-designer-master-detail-delete-behavior-options.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The field designer no longer offers a `master_detail` a `deleteBehavior` the schema refuses - -#9689 made `deleteBehavior: 'set_null'` authored on a `master_detail` field a -named parse-time rejection. The two shipped metadata forms that let a Studio -author pick that value did not move with it, so the designer kept offering -"Set null" for a `master_detail` — the author picked it and learned only at -publish, from a 422, that the choice was never legal. Declared-vs-enforced, one -seam earlier than the parse door. - -Both forms failed the same way through **different option sources**, which is -why the repair is not one edit twice: - -- `object.form.ts` declared an inline `options` array carrying all three values - behind a single `visibleWhen` that named both `lookup` and `master_detail`. -- `field.form.ts` declared **no** `options` at all. That is not a narrower - offer — it is the renderer's derived source: with no inline list the - metadata-admin form falls through to the JSON Schema `enum`, which is - `['set_null','cascade','restrict']` and additionally advertises - `default: 'set_null'`. A Zod enum has no per-type narrowing to give, so the - derived path re-offers the refused value by construction, and `master_detail` - can only be served by an explicit list. - -Both now declare the control **twice, with disjoint `visibleWhen`** — `lookup` -keeps all three outcomes, `master_detail` is offered `cascade` and `restrict` -only. Nothing is taken away from `lookup`, where all three remain legal: -`object.form.ts` keeps its three-value list unchanged, and `field.form.ts`'s -`lookup` branch keeps deriving from the enum exactly as before, so its labels -and their translation are untouched. The two branches are mutually exclusive, so -no author ever sees both. - -**Per-option `visibleWhen` was the tighter-looking spelling and was measured and -rejected.** `SelectOptionSchema` does declare it (ADR-0068), so it reads as -existing vocabulary, but it is not reachable from a metadata form in either -direction: the metadata-admin renderer maps `fieldSpec.options` straight to -select items and never consults it, so writing one would ship an ADR-0049 -declared-but-unenforced key; and on the runtime surface that *does* honor it, -the per-option evaluator binds `record` and never `data`, so a `data.`-rooted -predicate there is an unbound identifier whose visibility fails **open**, -keeping the option. Either way the author would still have been offered -"Set null" — a fix that silently does nothing. Field-level `visibleWhen` is the -predicate the renderer already evaluates for every other type-conditional -control in these files, so the split uses it and the form DSL is unchanged. - -The pinning test asserts more than the absent value: it requires **exactly one** -visible control per type, because two declarations of one key can fail by -overlapping (two selects writing one key) as well as by leaving a gap (the -control vanishes for that type). diff --git a/.changeset/file-hydration-tombstone-agreement.md b/.changeset/file-hydration-tombstone-agreement.md deleted file mode 100644 index 90141af4a5..0000000000 --- a/.changeset/file-hydration-tombstone-agreement.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/service-storage": patch ---- - -Record file-field hydration now answers the same question about a `sys_file` tombstone that the download path answers (#11427). `#10246` stopped `GET /api/v1/storage/files/:id` treating a tombstone (`status: 'deleted'` + `deleted_at`) as the last word — it asks the reap guard's own `findFileHolder` and serves the row for as long as something still holds it — but the record read kept the older `status === 'committed'` rule. One `sys_file` row therefore answered `200` at the download endpoint and a bare id inside a record payload, which UI and export render as "this record has no attachment". - -The population is narrow and unchanged in every other respect: `claimFile` already un-tombstones a field file synchronously when a record re-points at it, and attachments-scope files are never reached by field hydration, so what this closes is the residual the reap guard's sweep-time re-verification names — hook races, direct-driver writes, and future trash restore. A tombstone nothing holds still hydrates as a bare id, and a `pending` upload is untouched. - -The predicate is not re-derived in the engine. `ObjectQL` gains `registerHeldFileResolver` (type `HeldFileResolver`), which the storage plugin fills with `findHeldFiles` — the batched form of `findFileHolder`, asking the same union of `sys_attachment` join rows and the `ref_*` ownership columns. Batched because hydration runs over many rows per read: a read with no tombstone costs nothing, and the residual case costs one extra query for the whole read rather than one per file. Engines with no storage plugin keep tombstones un-hydrated exactly as before. diff --git a/.changeset/flat-input-descriptor-mirror.md b/.changeset/flat-input-descriptor-mirror.md deleted file mode 100644 index bdb8f6bce9..0000000000 --- a/.changeset/flat-input-descriptor-mirror.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the flat-input Proxy mirrors `data`'s own descriptor instead of synthesising one (#12397) - -`installFlatInput` hands a declarative hook a flat-record Proxy over the -engine's `{ data, options, id? }` wrapper. Its `getOwnPropertyDescriptor` trap -answered every key `data` carries with one fixed literal — -`{ configurable: true, enumerable: true, writable: true, value: data[prop] }` — -and never read `data`'s real descriptor. For a key created by ordinary -assignment that synthesis is the truth, which is why it cost nothing for as -long as assignment was the only way a key could arrive. - -#12277 routed `defineProperty` into `data`, so a hook can now put a key on the -record payload with non-default attributes for the first time, and the -synthesis reported the defaults back regardless: - -```js -Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true }); -Object.getOwnPropertyDescriptor(ctx.input, 'k'); // reported enumerable: true — it is not -Object.keys(ctx.input); // …while this correctly omitted 'k' -``` - -Two instruments over one payload, contradicting each other. The trap now -mirrors `data`'s own descriptor. - -`configurable` is the one attribute that cannot be mirrored: the proxy target -is the wrapper, which does not carry the record key, and a proxy may not report -a property its target lacks as non-configurable — a verbatim mirror throws -`TypeError` on any key `data` holds as `configurable: false`, and takes -`Object.keys` and spread down with it, since both reach every listed key -through this trap. It is forced `true`; `enumerable` / `writable` are mirrored. - -Two further observable consequences, both pinned: - -- Reading a descriptor no longer runs author code. The synthesis evaluated - `data[prop]` to fill `value`, so asking a payload that holds an accessor for - its descriptor invoked the getter; a mirror copies `get`/`set` across - untouched. -- `prop in data` is true for the whole prototype chain, so the synthesis - answered for inherited keys too — `Object.getOwnPropertyDescriptor(input, - 'toString')` returned an own, enumerable, writable data property no payload - has ever held, and `Object.hasOwn(input, 'toString')` was `true`. Only an own - key has a descriptor to mirror; inherited keys now report `undefined`, while - `'toString' in input` and the read itself are unchanged. - -Enumeration is untouched: `ownKeys` still lists exactly `data`'s own enumerable -keys and the mirror reports those as enumerable, so `Object.keys`, spread, -`Object.entries` and the sandbox's `unwrapProxyToPlain` see byte-identical -results. What a record payload may hold, how `defineProperty` routes into -`data`, and how the engine persists it are all untouched. diff --git a/.changeset/flat-input-envelope-precedence.md b/.changeset/flat-input-envelope-precedence.md deleted file mode 100644 index 1185d702d3..0000000000 --- a/.changeset/flat-input-envelope-precedence.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the flat-input Proxy's descriptor trap agrees with `get` about the four reserved names (#12601) - -`installFlatInput` gives `id` / `options` / `ast` / `data` precedence in the -`get` trap — a direct read (`ctx.input.id`) always resolves against the -engine's `{ data, options, id? }` wrapper (the envelope), never against the -record payload, even when the payload itself declares a field sharing one of -those names. `getOwnPropertyDescriptor` checked the payload FIRST instead, so -for an update whose payload happened to carry a same-named field: - -```js -const raw = { data: { id: 'PAYLOAD-ID', subject: 'help' }, options: {}, id: 'WRAPPER-ID' }; -ctx.input.id // 'WRAPPER-ID' (get) -Object.getOwnPropertyDescriptor(ctx.input, 'id').value // 'PAYLOAD-ID' (descriptor, pre-fix) -``` - -Two instruments over the identical key, contradicting each other — and -anything that copies a value out of a raw descriptor rather than through -`get` inherited whichever one this trap picked. - -Per the maintainer ruling on #12601 (Option A — "the envelope wins -consistently"): the four names are reserved on the hook flat-input face. -`getOwnPropertyDescriptor` now checks them first, exactly where `get` already -does, so a descriptor read can never again disagree with a plain read of the -same key. A payload field sharing one of the four names stays a legal record -field — it round-trips through storage unchanged — it is simply not reachable -through the flat face; `ctx.input.data.` is the only route to it. - -## Why `patch`, argued rather than assumed - -This is the same trap set, the same shape of fix, and the same scope as the -two immediately preceding fixes here — #12397 (descriptor trap mirrors `data` -instead of synthesising) and #12578 (`ownKeys` reports the payload's own key -set) — both shipped `patch`, and both changed what a specific instrument -reports for specific inputs, exactly as this one does: - -- **No persisted data moves.** Unlike #12277 (`delete` actually deletes, - shipped `minor` because it changes what lands in storage), this fix changes - only what a READ instrument reports; the row the engine writes is byte-for- - byte identical before and after. -- **The paths that already worked stay byte-identical.** `ctx.input.id` - itself (`get`), `Object.keys`/spread/`Object.entries` - (`ownKeys` + the descriptor's `enumerable` flag + `get`'s value) all - already answered the envelope's value for a reserved name before this fix — - measured, not assumed (see the test file). The only caller this changes is - one that reads `Object.getOwnPropertyDescriptor(ctx.input, '').value` directly, on a payload that ALSO happens to declare a field - by that exact reserved name — a combination narrow enough that it is the - disagreement itself, previously unnoticed, that this card exists to close. -- **It restores an invariant the code already claimed to hold** (`get` and - the descriptor trap were always supposed to agree — that is the whole - premise of a "flat view"), rather than adding or removing a capability. A - hook body that worked correctly before this fix — i.e. one that never - happened to hit the exact disagreeing combination — is unaffected. - -Not declared breaking, so no ADR-0087 disposition marker applies (this -changeset removes/renames no authorable spec key, export, or config field — -`check-adr-0087-registration.mjs` only judges changesets that declare a -breaking/major change). diff --git a/.changeset/flat-input-ownkeys-own-key-set.md b/.changeset/flat-input-ownkeys-own-key-set.md deleted file mode 100644 index 5abe17878e..0000000000 --- a/.changeset/flat-input-ownkeys-own-key-set.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the flat-input proxy's `ownKeys` reports the payload's own key set, not its enumerable subset (#12578) - -`installFlatInput` answered the `ownKeys` trap from `Object.keys(data)` — own **enumerable -string** keys. That filtering was incidental to what the trap is for (hiding the wrapper keys -`id`/`options`/`ast`/`data` from `Object.keys`/`for…in`), and it cost a key: an own -**non-enumerable** key on the record payload was absent from `Object.getOwnPropertyNames(input)` -and `Reflect.ownKeys(input)` while `hasOwnProperty` and the descriptor trap both reported it — -and while the engine persisted the row holding it. Measured on the merged ref, for a payload -`{ subject }` a handler had added `k` to with -`Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true })`: - -``` -Object.getOwnPropertyDescriptor(input, 'k') -> own, enumerable:false -Object.prototype.hasOwnProperty.call(input, 'k') -> true -Object.getOwnPropertyNames(input) -> ['subject'] <- not own? -Object.getOwnPropertyNames(persisted row) -> ['subject', 'k'] -``` - -Three instruments, one payload, two answers about own-ness. Newly reachable rather than newly -written: #12277 routed `defineProperty` into the payload, so a handler can put a -non-default-attribute key there for the first time, and #12397 made the descriptor trap mirror -the payload instead of synthesising defaults — which is what gave the third instrument an -opinion to disagree with. - -The trap now reports `Object.getOwnPropertyNames(data)`. **The enumerable face is unchanged**: -`Object.keys`, spread, `Object.entries`, `for…in` and `JSON.stringify` still omit a -non-enumerable key, because each applies the `enumerable` filter itself, one layer up, through -the descriptor trap. Applying it inside `[[OwnPropertyKeys]]` as well did not make those answers -cleaner — it only starved the two surfaces whose entire job is to report the whole set. The -sandbox body face is byte-identical for the same reason: `unwrapProxyToPlain` -(`@objectstack/runtime`) snapshots `ctx.input` as `Object.entries` over this proxy. - -Wrapper keys stay excluded, which is the trap's purpose — achieved by reading `data` and never -the wrapper, not by subtracting those four names, which would hide a genuine payload field named -`id`. **Symbol keys remain unenumerated**: they already reach the payload and already persist, so -publishing them through `ownKeys` is a question about what a record payload may hold rather than -about this trap, and it is left open on #12578 rather than decided here. - -Pinned in `hook-input-ownkeys-agreement.test.ts` as the AGREEMENT of the three own-ness -instruments — not as one trap's output, which is the pin shape that let the halves diverge — with -the wrapper-key and symbol exceptions pinned as deliberate exceptions. Reverse-verified by -ablation: restoring `Object.keys(target.data)` fails exactly 2 of the 6 new cases (32 of 34 green -across the four hook-input suites), and the enumerable-face assertions stay green under the -mutation, which is what proves that half untouched. diff --git a/.changeset/flow-contender-one-renderer.md b/.changeset/flow-contender-one-renderer.md deleted file mode 100644 index bdf53d09df..0000000000 --- a/.changeset/flow-contender-one-renderer.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -fix(service-automation): one renderer for the contested-flow phrase, and the two spellings it had drifted into (#12563) - -`minor`, not `patch`, and not empty: this adds a new export -(`renderFlowContender`) to a published package's public API, and it changes -**shipped operator-facing log text**. Both are real changes a consumer can -observe. - -## What changed - -One event — a flow name claimed by more than one definition — was described to -an operator in three places, each with its own private `const describe` beside -the log call: `flow-precedence.ts`'s precedence warning, `plugin.ts`'s bootstrap -audit, and (in `@objectstack/cli`) the startup banner. Nothing held them equal, -and two axes had already drifted: - -- **Quoting.** `flow-precedence.ts` rendered `package "crm"`; the other two - rendered `package 'crm'`. -- **Absent package id.** The two engine copies interpolated a bare `undefined` - into the sentence; the CLI copy rendered a real fallback. - -The two copies in this package are now one exported renderer. The choice on -each axis was measured, not voted: - -- **Single quotes**, measured against this package rather than across the three - copies: of the interpolated identifiers in operator prose under - `service-automation/src`, 203 are single-quoted and 3 double-quoted — one of - those 3 being this phrase. The sentence already single-quotes the flow name - beside it. -- **A named fallback** (`a code-shipped package (id unknown)`) instead of - `package 'undefined'`. This package's own callers cannot reach that branch - today, because `isCodeArtifactBody` is false on a falsy `_packageId` — but - that is a property of today's callers, not of an exported function. - -## Log text a consumer may be matching on - -`[Automation] Flow name collision: …` (the precedence warning) now renders a -packaged contender as `package 'crm'` rather than `package "crm"`. -`plugin.ts`'s bootstrap `[Automation] flow '' is claimed by …` warning is -byte-identical to before for every input its callers can produce; only its -unreachable absent-id branch changed. - -## Why the CLI still renders its own - -`@objectstack/cli` deliberately keeps its own spelling and takes no value -import of this package for the banner: its engine reads are structural and -feature-detected so a host on an older automation package still boots. The -third copy is held equal by a test-only agreement pin -(`packages/cli/src/utils/format.flow-contender-agreement.test.ts`) that asserts -the banner line through this renderer, so it goes red in both directions. diff --git a/.changeset/flow-expression-function-table.md b/.changeset/flow-expression-function-table.md deleted file mode 100644 index 02c980980c..0000000000 --- a/.changeset/flow-expression-function-table.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -Flow value expressions (`create_record`/`update_record` `config.fields`, `assignment` `config.assignments`) now support a small numeric function table — `round`, `floor`, `ceil`, `abs`, `min`, `max` — with every name and semantic mirrored 1:1 from the `@objectstack/formula` CEL stdlib (no second dialect: `round` is integer-only exactly like CEL's; for N-decimal rounding write `round(x * 100) / 100`, the same pattern CEL authors use). A flow can finally write a computed money value that satisfies its field's declared `scale` (`{round(amount * (1 - discount / 100) * 100) / 100}` → a `scale: 2` currency field). - -Loud diagnostic in the same stroke: an identifier in call position that is not a supported function — `ROUND(...)`, `Math.round(...)`, `(x).toFixed(2)`, or the next name anyone invents — now fails the node with a named `FlowExpressionFunctionError` (guard-marked, so a `fault` edge cannot swallow it) instead of being silently rewritten to `null` and writing the field as `undefined`. Non-call template resolution is unchanged: unresolved plain tokens still become `null`/empty, and `NOW()`/`TODAY()` whole-token macros behave exactly as before. diff --git a/.changeset/flow-input-schema-invalid-code.md b/.changeset/flow-input-schema-invalid-code.md deleted file mode 100644 index 381c1c7111..0000000000 --- a/.changeset/flow-input-schema-invalid-code.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): register `FLOW_INPUT_SCHEMA_INVALID` — the definition-level input-schema refusal becomes a never-dispatched exit with its own ADR-0112 code (#11504, the contract half of the #10025 ruling) - -`AutomationResult.code` gains `'FLOW_INPUT_SCHEMA_INVALID'`, and the code is -registered in the ADR-0112 error-code ledger under `@objectstack/runtime` -beside `FLOW_DISABLED` / `FLOW_NO_START_NODE`. Semantics: a node's static -`config` violates the `inputSchema` its own flow definition declares, so the -engine refuses to dispatch — nothing runs, nothing is written, the result -carries the code and NO `status` (the #9378 never-dispatched class), and a -transport maps it to **422** (unexecutable stored definition, exactly as -`FLOW_NO_START_NODE`). - -Ruled by #10025 (maintainer, 2026-08-20): the refusal is **non-retryable** — -the guard's verdict is a pure function of the flow definition, so re-running -it cannot change the answer. This release ships only the contract vocabulary; -the engine behaviour change is #10025's services half and lands separately. - -**Operator-visible consequence once that services half lands, stated -plainly:** retry accounting and run-log volume change for affected flows. A -`strategy: 'retry'` flow whose node `config` violates its declared -`inputSchema` today burns its whole retry budget (including configured -backoff delays) and writes `1 + maxRetries` identical failed run-log rows; -after the services half it refuses **once**, producing **one** run-log row -carrying `code: 'FLOW_INPUT_SCHEMA_INVALID'` and no `status`. Anything -watching retry counters or paging run history for this exit sees different -numbers for the same flow. diff --git a/.changeset/flow-screen-copy-resolver-family.md b/.changeset/flow-screen-copy-resolver-family.md deleted file mode 100644 index 4bb8d1ea38..0000000000 --- a/.changeset/flow-screen-copy-resolver-family.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**i18n:** the screen-flow copy vocabulary gains its resolver family — `translateFlow`, `resolveFlowScreenTitle`, and the `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` shared key lists (#7646 recommendation B, the spec half of #11287). - -#7763 declared `TranslationData.flows` (`flows..label`, `.screens..title`, `.screens..fields..{label,placeholder}`) and deliberately left the resolver half unwritten because another change was in flight on `i18n-resolver.ts`. This lands that half in `packages/spec/src/system/i18n-resolver.ts`, mirroring the page family's conventions: - -- `translateFlow(flow, bundle, opts)` — the metadata-document overlay: translates the flow's own `label` and, for every `type: 'screen'` node with an id, the screen heading (`config.title` — written even when the author relied on the node-label fallback, since the executor builds the wire title as `config.title ?? node.label`) and per-field `label` / `placeholder`, key by key across the locale chain with the authored source strings as fallback. Input not mutated; off-spec bundle keys the schema refuses (`description`, `help`) are ignored, never overlaid. -- `resolveFlowScreenTitle(bundle, flowName, screen, opts)` — the piecemeal half for a caller already holding a `ScreenSpec` (`nodeId` addressing, `title` literal fallback). -- `FLOW_SCREEN_COPY_KEYS` (`['title']`) and `FLOW_SCREEN_FIELD_COPY_KEYS` (`['label', 'placeholder']`) — the one list the resolver overlay and the CLI's skeleton extractor (downstream card) both import, pinned against `TranslationDataSchema` so neither can drift. -- Supporting shapes: `FlowLike`, `FlowNodeLike`, `FlowScreenLike`, `FlowScreenFieldLike`, `FlowScreenCopyKey`, `FlowScreenFieldCopyKey`. - -Additive surface widening only — no existing shape changes meaning, and `translateFlow` is deliberately **not** registered in `translateMetadataDocument`'s dispatch table: that registration reaches the REST metadata boundary by itself (`TRANSLATABLE_METADATA_TYPES` drives `@objectstack/rest`), which would stand up a shipped reader of the `flows` group while its liveness ledger rows are `planned`. The runner application (server or client side), the CLI coverage bucket, and the ledger flip to `live` all ride the downstream cards of #11287. diff --git a/.changeset/form-predicate-root-vocabulary-per-surface.md b/.changeset/form-predicate-root-vocabulary-per-surface.md deleted file mode 100644 index 8b2d4ac7d4..0000000000 --- a/.changeset/form-predicate-root-vocabulary-per-surface.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata-core": patch -"@objectstack/metadata": patch ---- - -fix(metadata-core,metadata): split the form-view predicate root vocabulary per surface, so a field-level `current_user` test is not false-flagged (#12915) - -Same-day correction to the unbound-root boot notice. The notice judged **every** -form-view predicate against one vocabulary (`record` / `previous` / `parent` / -`data`), sourced faithfully from the contract prose — which, for the field-level -slot, was stale. - -`current_user` and its ADR-0068 alias roots (`user`, `ctx.user`, `os.user`) -**resolve on a field-level `visibleWhen`** since objectui#6010; three spec text -sites still said otherwise until #12930 re-measured them, and one of those sites -was the sentence this policy was written against. A legacy artifact carrying a -legitimate `current_user.role == "admin"` field predicate was therefore reported -as faulting open — the cry-wolf failure the notice is explicitly built to avoid, -and the one that trains operators to ignore the channel. - -The vocabulary is now per surface, which is what the contract actually says: - -- **Field-level** (`BOUND_FORM_FIELD_PREDICATE_ROOTS`): the shared base plus - `current_user`, `user`, `ctx`, `os`. Silent on all of them. -- **Section-level** (`BOUND_FORM_VIEW_PREDICATE_ROOTS`, unchanged in name and - value): the base alone. `current_user` is still flagged there — the section - docblock states it is unbound at that level and faults open. - -Two limits of the field binding deliberately do **not** change the answer: it is -a rendering rule rather than authorization (an authoring hazard, not a -version-drift one), and the scope is empty on the console's public `/f/:slug` -route (equally true of a freshly built current artifact, so it says nothing -about the artifact's era — the only thing this notice claims to detect). - -The emitted warn line now prints the bound roots **per surface, and only for the -surfaces the findings implicate**, so an operator is never shown a rule their -artifact has no instance of. Findings carry a `surface` field. - -`unboundRootsInCelSource` takes the vocabulary as an optional second argument; -its default is unchanged (the stricter base), so existing callers behave exactly -as before. diff --git a/.changeset/form-view-features-root-refused.md b/.changeset/form-view-features-root-refused.md deleted file mode 100644 index 7324c8ccdf..0000000000 --- a/.changeset/form-view-features-root-refused.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Form-view predicates loudly reject the `features.*` scope root (objectstack#12665; ruled 2026-08-27 on objectui#6262, option B — vocabulary narrowing). One authored form view is served on two kinds of route, and a `features.*` predicate got two verdicts from the same text: inside an app (`/apps/:appName/*`) the root resolves against the real auth-config flags, while on the standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, the root is unbound, the predicate faults — and `visibleWhen`'s fault fallback is visible, so the field or section a feature flag was meant to hide was shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch: zero authored `features.*` form-view predicates exist across objectui apps/examples/content (18-hit positive control on authored `visibleWhen` generally), so the vocabulary is narrowed at the authoring door instead of building auth-config parity machinery for zero consumers. - -What newly gets rejected: any form-view predicate naming `features` in root position — dotted member access (`features.x`), index access (`features['x']`), or the bare root — outside string literals; on section-level `visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option `visibleWhen` authored inline in the form view, including the deprecated `visibleOn` alias spellings and the flattened runtime form overlay. The refusal is a prescriptive parse issue naming the root, the surface, the fail-open reason and the ruling. Member access on a record field that happens to be named `features` (`record.features.x`) stays legal, as does every other root; app-context predicate surfaces (page components, actions, bulk-action eligibility) keep `features.*` exactly as before. - - diff --git a/.changeset/form-view-option-default-narrowed.md b/.changeset/form-view-option-default-narrowed.md deleted file mode 100644 index 4e0a423cec..0000000000 --- a/.changeset/form-view-option-default-narrowed.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): narrow the per-option `default` key OUT of the form-view options vocabulary — the object-field face keeps it enforced (#12868) - -**BREAKING** accept-set narrowing on the published FormView vocabulary, shipped -as `minor` under the repo's launch-window convention for breaking changes. - - - -`SelectOptionSchema` serves two surfaces, and only one of them reads the -per-option `default` key: - -- **Object-field options** (`Field.select.options`): ENFORCED and UNTOUCHED — - `applyFieldDefaults` falls back to the option marked `default: true` when the - field declares no `defaultValue`, `defaultValue` wins when both are declared, - and the alias rows (`isDefault`/`selected` → `default`) stay. -- **Form-view options** (`FormFieldSchema.options` inside a FormView): the same - key parsed clean and nothing read it — the engine's insert-path fallback - consults the OBJECT definition's options, never a form view's, and no form - renderer seeds a value from it. The maintainer-ruled disposition (2026-08-28, - disposition 甲) narrows the key out of this face only. The ruled census - measured ZERO occurrences of `default` (and the alias spellings - `isDefault`/`selected`) inside form-view options across the tree, the example - apps and the published `*.form.ts` corpus, with the instrument's positive - control hitting the enforced object-field usages. - -FROM → TO, and the one-line fix: - -- FROM: `{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open', default: true }] }` - inside a form view's `sections[].fields[]` (or nested `fields`) — parsed - clean, did nothing. -- TO: delete the key from the form-view option. Declare the pre-selected choice - on the OBJECT definition instead — field-level `defaultValue`, or - `default: true` on that field's own `options` entry (both enforced there). -- The new `FormSelectOptionSchema` (an Omit-derivation of `SelectOptionSchema` - minus exactly `default`) refuses the key with a tombstone prescription - carrying this mapping; `isDefault`/`selected` get guidance pointing at the - object definition instead of a rename toward a key the shape refuses. -- The protocol-18 conversion `form-view-option-default-removed` strips the key - from stored sources (pure lossless delete — it never had an effect on this - surface to lose). Run `os migrate meta --from 17` to list the mechanical - edits for existing sources; apply them by hand. diff --git a/.changeset/formfield-constraint-shape.md b/.changeset/formfield-constraint-shape.md deleted file mode 100644 index 5f5311c54d..0000000000 --- a/.changeset/formfield-constraint-shape.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`FormFieldSchema`'s constraint keys converge on the value shapes the object-field surface landed (#12174): `maxLength`/`minLength` tighten from bare `z.number()` to `z.number().int().min(1)` (the #11566/#11949 template — a character length is a positive integer, and `minLength: 0` is a vacuous always-true declaration refused loudly; "no minimum" is expressed by omitting the key), and `precision`/`scale` tighten to `z.number().int().min(0)` (the #8321 template — digit counts). `min`/`max` are unchanged: they are values, not counts, exactly as on the object-field surface. - -The row keys are live, measured in objectui: the spec bridge (`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy all six onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (objectui#5595), the fields package builds validation rules from `minLength`/`maxLength`/`min`/`max`, and `precision`/`scale` drive the number widgets — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and `GET /forms/:slug` serves the rows verbatim to anonymous renderers. What newly gets rejected: `0` (length pair only), negative and non-integer values on the four count-shaped keys. Unlike the object-field twins there is no type-conditional applicability gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable here, key placement stays the object field's own schema's job. Already-legal declarations round-trip byte-identically, and absence stays absence — no default materializes. - - diff --git a/.changeset/four-stale-current-user-binding-sites.md b/.changeset/four-stale-current-user-binding-sites.md deleted file mode 100644 index 6bcb088d1a..0000000000 --- a/.changeset/four-stale-current-user-binding-sites.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/lint': patch ---- - -Re-measure four stale `current_user` binding-text sites, including the form SECTION slot - -The claim that `current_user` is unbound on a form-view **section** predicate was true when -it was written and is not any more: the console form renderer threads the host shell's -predicate scope into `isSectionVisible` (objectui#6110), and the object-view chain now -carries an authored `section.visibleWhen` through to an evaluator via the `section-divider` -pseudo-field (objectui#6111). Text only — no schema, no verdict and no runtime behaviour -moves. - -- `FormSectionSchema.visibleWhen` (`ui/view.zod.ts`) — the JSDoc and `describe()` now say the - root resolves, carrying the two qualifications the field-slot text already carried: the - binding is **client-side only** (no write-path evaluator reads a form-view section or field - `visibleWhen` — the rule validator's list is field `readonlyWhen` / `requiredWhen` and - per-option `visibleWhen`), and the scope is **empty on the public `/f/:slug` route**, which - is mounted outside any provider on purpose. The `features.*` refusal sentence is unchanged: - that root is unbound on both standalone form routes. -- `SelectOptionSchema.visibleWhen` (`data/field.zod.ts`) and - `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`) — the retired - exclusivity claim ("the one `*When` surface where `current_user` resolves") is trimmed. The - durable grounding stays and is now what the prescription rests on: per-option is the one - visibility predicate the **server** enforces, so the rule validator refuses a write of a - value whose predicate is false. -- `@objectstack/lint`'s field-rule message — the `visibleWhen` consequence clause is - re-measured. Under a scope-publishing host the predicate no longer faults: it resolves, the - control is hidden client-side, and the server still returns the value to every other reader - — a silent enforcement gap. The fault-open leg survives wherever no host publishes a scope. - The verdict is unchanged and the message says why it is now *more* justified: trading a loud - lint error for a gap nobody can see is worse than the error. diff --git a/.changeset/front-door-examples-compile.md b/.changeset/front-door-examples-compile.md deleted file mode 100644 index 152a81af24..0000000000 --- a/.changeset/front-door-examples-compile.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Fix four self-contained TSDoc `@example` blocks in `packages/spec/src` that did -not compile against the package's own built declarations, and add `os:check` -markers so `check:skill-examples` keeps them honest going forward. - -The package's front-door header (`src/index.ts`) taught a root namespace-import -style (`import { Data, UI, System, Auth, AI, API } from '@objectstack/spec'`) -the root has never exported, and two subpath-import styles that named -`@objectstack/spec/auth` — a subpath that does not exist (the real one is -`@objectstack/spec/identity`). The header now documents three styles that -actually compile: root-level `defineX` factory imports (`defineStack`, -`defineView`, `defineApp`, `defineFlow`, `defineAgent`, `defineTool`, -`defineSkill`, …, which the root really does export), namespace imports via -subpath, and direct subpath imports — all against `@objectstack/spec/identity` -instead of the non-existent `/auth`. - -`src/shared/branded-types.zod.ts`'s `@example` imported `ObjectNameSchema` / -`FieldNameSchema` from the package root; both are exported only from -`@objectstack/spec/shared`. Fixed the specifier. - -No behavior change — TSDoc examples only. diff --git a/.changeset/generator-filename-registry-parity.md b/.changeset/generator-filename-registry-parity.md deleted file mode 100644 index 2573d8b73e..0000000000 --- a/.changeset/generator-filename-registry-parity.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os generate` writes every scaffold as `NAME.TYPE.ts`, with the infix read from -the metadata type registry instead of the harness - -`os g object customer` wrote `src/objects/customer.ts`. The metadata loader -discovers files by globbing each type's own `filePatterns` from -`DEFAULT_METADATA_TYPE_REGISTRY` — `MetadataPlugin._loadFromFileSystem` does -this for **every** registered type, and it is the default `eager` bootstrap -whenever no compiled artifact is configured. Measured across the seven -generators: the bare `NAME.ts` the harness wrote matched **zero** of those -patterns for all seven, while `NAME.TYPE.ts` matched exactly one, every time. -A scaffold that matches nothing still type-checks, still passes `os validate` -and still publishes, and is then never loaded, with no diagnostic at any step -— the silent-strip shape ADR-0063's retirement of `os g agent` closed. - -The previous release closed this for `skill` alone, through an optional -per-generator filename override, on the belief that the other six types were -not filesystem-discovered. The loader says otherwise, so the override is -retired rather than copied five more times: the filename is now derived from -the type's declared pattern, which is also why it reads the pattern rather than -interpolating the type key — `email_template` declares `*.email-template.ts` -and `external_catalog` declares `*.external-catalog.ts`, so an interpolated -infix would re-create the same invisibility for the next generator added. - -Now written: `customer.object.ts`, `customer.view.ts`, `approve.action.ts`, -`customer.flow.ts`, `sales.dashboard.ts`, `crm.app.ts`, -`lead_qualification.skill.ts`. The barrel `index.ts` line is derived from the -filename that was actually written, so it names the real module. - -**Existing files are not renamed and keep working.** A `NAME.ts` file already -on disk matched no `filePatterns` entry before this change either, so nothing -it relies on moves: if your app loads it through the barrel `index.ts` the -generator wrote, that import is untouched. Rename it to its type's pattern if -you also want the loader's own glob to find it. diff --git a/.changeset/global-search-registry-propagate.md b/.changeset/global-search-registry-propagate.md deleted file mode 100644 index ab224e4e67..0000000000 --- a/.changeset/global-search-registry-propagate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Global search (`searchAll`, the producer behind `GET /api/v1/search`) no longer invents an empty registry. The registry read was `registry?.getAllObjects?.() ?? []`, so a host whose registry does not implement `getAllObjects` — a structural omission that never throws — produced a successful "nothing matched" response with `objectsScanned: 0`. A registry that cannot enumerate its objects is never truthfully "no objects" (ADR-0110 D3): both halves of the swallow are removed and the omission now surfaces as the read's own failure, mapped by the REST door's standard error path. Unchanged neighbours: a registry that enumerates and truthfully answers "no objects" still yields the successful empty response, and a blank query still short-circuits before the registry is consulted. diff --git a/.changeset/great-poems-repeat.md b/.changeset/great-poems-repeat.md deleted file mode 100644 index 4a3e3ac5be..0000000000 --- a/.changeset/great-poems-repeat.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`translatePage` now resolves copy for components nested in a container's declared `properties.children` array, recursively — previously it visited region-level components only, so copy authored under `pages..components.` for a nested id parsed happily and was never applied (measured: four KPI labels nested in a `page:card` stayed English on an otherwise fully translated page). - -The published translation face does not change; the resolver widens to match the face it already accepted. When an id appears more than once, a region-level component carrying it wins outright, and among nested components the document-order first match takes the entry. The descent follows `children` only — `body` remains a renderer-side back-compat spelling, not an authoring key — and is depth-capped and cycle-safe, since `children` is authored data. diff --git a/.changeset/hash-shadow-unique-key-utf8mb4.md b/.changeset/hash-shadow-unique-key-utf8mb4.md deleted file mode 100644 index 79ad12fad3..0000000000 --- a/.changeset/hash-shadow-unique-key-utf8mb4.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -driver-sql (MySQL): carry an over-long UNIQUE index on a hash-shadow column - -On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a -full-value UNIQUE index over a longer column is inexpressible — an OAuth access -token that is a multi-KB JWT cannot be made keyable by any declared bound. -Measured on live MySQL 8.0.46, 7 of 44 exported platform objects failed -`syncSchema` outright and landed registered with their declared uniqueness -absent (Postgres 16.13: 0 of 44). - -Such a UNIQUE index is now carried by a driver-owned `__hash` column — a -`STORED GENERATED` `VARBINARY(32)` holding the full, untruncated SHA-256 of the -key values — with the unique index on that column. Uniqueness is still enforced -over the whole value: distinct values sharing a long prefix are both accepted -(the property that ruled out prefix-unique indexes), NULLs stay distinct, and a -composite tuple containing NULL conflicts with nothing. - -The shadow is created only *after* the server refuses the direct index, so the -dialect divergence is selected by the error code rather than by a dialect check: -Postgres and SQLite are byte-identical to before. Non-unique indexes are -deliberately left refused — an index over a digest accelerates no lookup the -planner can reach. diff --git a/.changeset/heavy-eels-shave.md b/.changeset/heavy-eels-shave.md deleted file mode 100644 index 24bc98dfbc..0000000000 --- a/.changeset/heavy-eels-shave.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/runtime": minor ---- - -Packaged flows can be switched off durably, and the process-local off-switch is retired - -Disabling a packaged flow now writes an install-level row to the -`sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a -process-local map. The engine consults that ledger at the `execute()` seam — -the one seam every entry path crosses (record-change, schedule, time-relative, -api, subflow) — and refuses a disabled flow there with the existing -`FLOW_DISABLED` code; the ledger case is distinguished by the message, so no -new error code joins the ADR-0112 ledger. An install-level disable also unbinds -the flow's trigger, and re-enabling rebinds it. Absence of a row means the -packaged default, active, so a deployment that never flips anything behaves -exactly as before. - -This retires the mechanism behind #10243 rather than refining it. The old -`flowEnabled` map was not a row, so no organization wall scoped it: on a walled -multi-organization deployment a tenant org owner could switch a shipped flow -off environment-wide and an unrelated tenant read it off. The durable row -replaces it, and because a durable install-wide switch writable by tenants -would be that leak with persistence, the write is now authority-gated: -`POST /automation/:name/toggle` requires the platform operator in the `group` -and `isolated` postures, while the `single` posture — where install-level and -org-level are the same scope — is unchanged for the org admin who already holds -`manage_metadata`. The refusal names the posture and points at the clone path. - -Disabling a flow that packaged flows still call as a subflow is refused, and -the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks -mid-run at its subflow node with an inexplicable late failure. The check is a -definition scan at disable time over both `subflow` and `map` nodes; no -reference index is built. Enabling is never guarded. - -One behaviour change worth calling out: a disable now survives -unregister-and-re-register, which is what a package upgrade, a Studio publish -and the boot pull all do. ADR-0126 §6 requires it — the ledger records the -customer's choice, and no upgrade un-makes a choice — but it is the opposite of -what the retired in-process map did, where any re-registration silently -re-armed the flow. diff --git a/.changeset/hmr-dev-door-environment-gate.md b/.changeset/hmr-dev-door-environment-gate.md deleted file mode 100644 index d0d87f3441..0000000000 --- a/.changeset/hmr-dev-door-environment-gate.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -fix(security): gate the metadata HMR door on an explicit development posture (#12140) - -**BREAKING** surface narrowing — `GET`/`POST /api/v1/dev/metadata-events` are no -longer mounted unless `NODE_ENV` is exactly `development`. Shipped as `minor` -under the repo's launch-window convention for breaking changes. - -`MetadataPlugin` mounted both HMR routes whenever a raw-app-capable HTTP server -was present, with no authentication and no environment condition of its own. The -dev-only posture the path (`/api/v1/dev/…`) advertises lived only in prose — -"production deployments simply won't have a CLI POSTing to this endpoint" — which -is a claim about who is on the network, not a gate that stops them, and the same -structural shape #9391 closed for the `datasource-admin` family: both mounts take -the host's framework-native app handle, which is outside REST's `enforceAuth` -seam by construction. - -The distributions were enumerated rather than assumed, because "a dev-only -surface lacking a gate that says so" and "an unauthenticated door on a real -deployment" want different repairs. The official container image runs `os start` -under `NODE_ENV=production`; that boot reaches `createStandaloneStack`, which -composes `MetadataPlugin` unconditionally (only `artifactWatch` was -NODE_ENV-gated) onto a kernel that registers the Hono server whenever it serves. -So a production-shaped boot did mount both routes and did answer them — `POST` -re-reading the compiled artifact from disk and broadcasting a reload frame to -every connected client, unauthenticated. - -`registerMetadataHmrRoutes` now refuses at its first statement, ahead of every -side effect it performs, and returns `null` so "nothing was mounted" is a fact -the compiler forces its caller to handle. Unset `NODE_ENV` is closed, per the -maintainer's 2026-08-06 ruling that an absent value reads as `production`; `test` -and unrecognised spellings (`staging`, `preview`, `qa`) are closed too — a gate -must not treat a spelling nobody recognises as a key. - -Who is affected, in both directions: - -- `os dev` is unchanged. It spawns `os serve --dev`, which sets - `NODE_ENV='development'` before any plugin starts, so the watch-recompile loop - still gets its `200` and Studio still gets its reload frames. -- A deployment that was reaching this endpoint on a production-posture boot now - gets its host app's `404`. That door was never advertised, never in the SDK - (`@objectstack/client` builds no such URL) and is a build-tool loopback; if you - need a supported production reload trigger, that is a product decision, not - this endpoint. -- The server-side artifact-file watcher is untouched on every boot shape that had - it: the reload still happens, only the broadcast to (now absent) SSE clients is - skipped. - - diff --git a/.changeset/hook-ctx-title-accessor.md b/.changeset/hook-ctx-title-accessor.md deleted file mode 100644 index 32b69a3d02..0000000000 --- a/.changeset/hook-ctx-title-accessor.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/runtime": minor -"@objectstack/cli": minor ---- - -**Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). - -A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. - -What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. - -```js -// this record — nameField, formula or stored column alike -await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); -// a related record, through the lookup column that holds its id -const account = await ctx.title('account_id'); -``` - -**Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. - -**Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. - -**It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. - -Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. diff --git a/.changeset/hook-input-delete-lands.md b/.changeset/hook-input-delete-lands.md deleted file mode 100644 index fa9fbb6783..0000000000 --- a/.changeset/hook-input-delete-lands.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/runtime': minor ---- - -fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277) - -A hook that stripped a field from its input with `delete` did nothing, on BOTH -execution paths, while an assignment made two lines above it on the same object -in the same call landed normally. Nothing raised, and nothing in the platform -reported it. - -Graded `minor` rather than `patch` deliberately: it moves data that reaches -downstream consumers. Any shipped hook that already contains -`delete ctx.input.` has been a no-op until now and starts taking effect -on upgrade — which is the point, and is also exactly why it must not arrive as -a silent patch. No API is removed and no accept set narrows. - -### The two mechanisms, which were unrelated and produced one outcome - -**In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).** -The flat-record `Proxy` a declarative hook receives over the engine's -`{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` / -`getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell -through to `Reflect.deleteProperty` on the WRAPPER, one level above the record, -removing a key that was never there and returning `true`. `set` was trapped and -wrote into `data`, which is what the engine persists; hence assignment survived -and deletion evaporated. - -**Sandboxed (`applyMutationsToInput`, -`packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations -were written home with `Object.assign(target, result.mutatedInput)`. -`Object.assign` copies own enumerable properties and **has no way to represent a -removal**: a key the VM deleted is simply not in the snapshot, and the host's -key stayed. Deletions are now diffed against the entry snapshot and applied -separately. - -Both are fixed in one change on purpose. Closing either alone would make the -same authored `delete` behave differently depending on whether the hook body -runs in-process or in the sandbox — a worse contract than the symmetric silence -it replaced. - -### What an author could see, before and after - -The sandboxed path is the one with no tell at all. Measured on the pre-fix code, -one hook call, host row alongside: - -``` -delete ctx.input.internal_notes -> true -'internal_notes' in ctx.input -> false <- the VM agrees -Object.keys(ctx.input) -> ['subject'] <- ...and so does this -host ctx.input after write-back -> { subject: 'HELP', - internal_notes: 'STAFF-ONLY' } -``` - -The in-process path was less deceptive than reported, and the correction is -worth having in writing: only `delete`'s own return value lied there. `'k' in -input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key -as present, so an author who checked with anything other than the return value -would have seen the no-op. - -### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it - -Found while enumerating the trap set, fixed in the same stroke because it is the -strictly worse shape: it defined on the wrapper, and the `get` trap's -fall-through then read the value straight back — so `input.k` CONFIRMED a write -that never reached `data`, while `Object.keys(input)` denied it and the record -never received it. It now routes into `data` like `set` and `deleteProperty` do. -One inherited JS invariant follows: a proxy may not report success for an -explicitly `configurable: false` descriptor its target does not carry, so -`Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws -a `TypeError` where it used to define, silently and uselessly, on the wrapper. -Omitting `configurable` — the common spelling, and the one spread and -`Object.assign` produce — is unaffected. - -### The direction the sandbox write-back deliberately does not overreach in - -Absence from the exit snapshot is the only evidence a deletion leaves, and on its -own it is ambiguous: a key whose host value is `undefined` (or a function, or a -symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from -the dump without anyone having deleted it. The diff is filtered through the same -JSON lens the boundary uses, so such a key is left alone. Every failure mode of -that probe is conservative — an unprobeable key is simply not deletable — because -losing a delete is recoverable and destroying a field on evidence that was never -there is not. One residual miss follows and is named here rather than discovered -later: a `bigint`-valued key crosses into the VM as a string but is dropped by the -probe, so deleting one is still lost. - -Measured consumer cost of the reported half: a guest-intake app stripped the -fields an anonymous web-to-case / web-to-lead submitter must not write — -internal staff notes, the resolution, the escalation flag, the owner — with -fifteen `delete` statements, every one inert. A submission carrying -`internal_notes` and `resolution` stored them verbatim, and the app's unit tests -stayed green throughout, because they drive the handler with a plain object where -`delete` genuinely works. diff --git a/.changeset/hook-input-symbol-key-refusal.md b/.changeset/hook-input-symbol-key-refusal.md deleted file mode 100644 index 2b19d26b82..0000000000 --- a/.changeset/hook-input-symbol-key-refusal.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql): the flat-input proxy REFUSES a symbol key at `set`/`defineProperty` instead of silently persisting it (#12603) - -**Bump level, argued**: `minor`, not `patch`. Every sibling in this family (#12277, -#12397, #12578, #12601) shipped `patch` because each closed an instrument -DISAGREEMENT — the accepted set of writes never changed, only which read-back -told the truth about them. This card is different in kind: `ctx.input[sym] = -value` and `Object.defineProperty(ctx.input, sym, …)` **used to succeed**, and -now **throw**. That is a narrowing of the accept set on `ctx.input` — a surface -every hook body touches — which is the exact shape `8cc8401` -(`@objectstack/objectql` 17.2.0, "BREAKING (accept-set tightening)") argued -`minor` for under this repo's launch-window convention (pre-1.0 semantics: a -breaking change does not burn a major version while the stack versions in -lockstep — see `scripts/check-changeset-no-major.mjs`). `patch` here would -under-declare a change that can turn a passing hook into a throwing one. - -**What changed.** `installFlatInput`'s `set` and `defineProperty` traps -(`packages/objectql/src/hook-wrappers.ts`) now refuse a symbol-keyed write with -a `TypeError` naming the key kind and the surface, instead of routing it into -the record payload (`data`) the way every string-keyed write is routed. -Measured on the pre-fix tree: a symbol-keyed `set` succeeded silently, the -value reached `data` and persisted to the row the engine stores, and only -`Object.getOwnPropertySymbols` / `Reflect.ownKeys` omitted it from enumeration -— two instruments said "own", enumeration said "no", while the persisted row -held it regardless. - -**Why a refusal, not a fourth instrument fix.** Maintainer ruling, 2026-08-27 -(Option C, refusal arm), on the payload-contract question #12578 measured and -deliberately left open rather than decided: a record payload is a declarable, -**string-keyed** field set — no metadata schema can declare a symbol field, so -a symbol key on this surface is a JS-runtime artifact leaking toward storage, -not a legal payload field. Option B (publish symbols too, via -`Reflect.ownKeys`) was declined — it would have made an undeclarable key kind a -published contract instead of closing the question. Hiding a key the engine -nonetheless persists is precisely the shape #12277/#12397/#12578 exist to -abolish; refusing the write at the boundary closes that gap from the other -side, before persistence rather than after enumeration. - -`ownKeys` itself is **untouched** — still `Object.getOwnPropertyNames(data)`, -exactly as #12578 landed it. With the write refused, `data` can never carry a -symbol key for that trap (or `Reflect.ownKeys`) to disagree about, so there is -nothing left for this card to change there. - -**Migration.** Code that wrote a symbol key onto `ctx.input` — almost always by -accident, e.g. spreading an object that carried a symbol-keyed cache entry onto -the payload — now throws instead of silently losing the write to enumeration. -Use a string key, or keep the value off the payload entirely (a local -variable, or a WeakMap keyed by the record) if it was never meant to be -stored. No other hook-input read/write path changes: reads, `has`, `delete`, -and every string-keyed write behave exactly as before. - -Inverts the pin `hook-input-ownkeys-agreement.test.ts` carried OPEN since -#12578 (the disagreement, deliberately left standing) into a REFUSAL pin -(the write throws, nothing persists) — the same case, turned around in place, -not a second assertion stacked beside the first. - - diff --git a/.changeset/hot-reload-inert-state-strategies-retired.md b/.changeset/hot-reload-inert-state-strategies-retired.md deleted file mode 100644 index ccb3531d9d..0000000000 --- a/.changeset/hot-reload-inert-state-strategies-retired.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) - - - -**BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 -cut (the lockstep launch-window convention ships it as `minor`; the -prescription is registered under protocol major 18 — -`RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry -`hot-reload-inert-state-strategies-retired` — where `os migrate meta` users -will look). - -This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 -ruling deliberately kept. That ruling retired the authorable lifecycle-config -container and kept `HotReloadConfigSchema` as a host-driven library parameter -type; this change measures the kept vocabulary's own remainder and finds the -same defect in it. The keep itself stands — `HotReloadConfigSchema`, -`PluginStateSnapshotSchema` and the health vocabularies still export, and -`HotReloadManager` / `PluginHealthMonitor` are untouched. - -The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both -wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said -"memory fallback" — and announced the substitution at DEBUG level only. A host -that asked for durable or cluster-replicated state got process-local memory -and no error: state that does not survive the restart it was configured to -survive. `distributedConfig` had ZERO readers anywhere, so an author could -name a Redis endpoint, a TTL and a replication factor and nothing ever opened -a connection. - -FROM → TO: - -- `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime - behaviour, because `'disk'` already stored to memory. It is the spelling - that was false, not the behaviour. -- `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or - `'none'` to disable state preservation outright. -- `distributedConfig: { … }` → *(removed)* — delete the key. It left with the - `'distributed'` value its own doc comment called it "required" for. -- `DistributedStateConfigSchema` / `DistributedStateConfig` / - `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of - that one key. - -One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete -any `distributedConfig` — you were already getting in-memory state. There is -no in-tree replacement for durable or distributed plugin state; persist it in -the host, which owns the process lifetime these strategies pretended to -outlive. Real disk or distributed persistence returns only via the ENFORCE -route of ADR-0049 — the implementation first, the declaration with it. - -The retirement kit: - -- **enum-value narrowing** (`['memory','disk','distributed','none']` → - `['memory','none']`): invisible to all four ratchets by construction (the - def still emits), so the prescription hangs on the enum's own `error` map - dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` - precedent. A value that was never legal still gets zod's own enum message, - so a typo is not told it "was removed". -- **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable - surface: no metadata-type binding, stack collection or manifest embed ever - carried it, and nothing in the tree parses `HotReloadConfigSchema` outside - its own unit test, so there is no authored document to rewrite and nobody - who could receive a parse-time tombstone): `kernel/DistributedStateConfig` - in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as - a def removal must — `api-surface` −3, `authorable-surface` −8, - `json-schema.manifest` −1. -- **runtime doors** in `@objectstack/core`, because route 3 leaves no - parse-time prescription: `HotReloadManager.registerPlugin` now refuses an - unhonoured `stateStrategy` and a leftover `distributedConfig` with an - ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the - prescription. Refused BEFORE the `enabled` check, so a disabled config - cannot smuggle the false declaration through. TypeScript hosts never reach - it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a - compile error at the call site. -- **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the - #11825 survivor list, so this reverses one line of that ruling on new - evidence — #11825 measured the container's six groups, never this key's own - readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` - moves in the same commit with the reasoning recorded beside it, and asserts - the surrounding keep is intact. -- zero in-tree consumers passed `'disk'` or `'distributed'` (measured at - cdbd9204b6 with a firing positive control; every live caller passes - `'memory'` or `'none'`), so no in-repo source changes ride along. diff --git a/.changeset/hot-reload-watch-placeholder-retired.md b/.changeset/hot-reload-watch-placeholder-retired.md deleted file mode 100644 index c1cc94ff5f..0000000000 --- a/.changeset/hot-reload-watch-placeholder-retired.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry -`hot-reload-watch-placeholder-retired` — where `os migrate meta` users will -look). Graded `minor` rather than `major` for the same reason #12340 was one -day earlier, in this same module. - -ADR-0049 applied one symbol over from #12340, in the same file and on the same -per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and -`PluginStateSnapshotSchema` still export, and `HotReloadManager` / -`PluginHealthMonitor` are untouched apart from the two doors below. - -`HotReloadManager.startWatching` contained **no watcher**. Its whole body was a -guard plus `logger.info('File watching started', { patterns })`, above an -in-source note saying real watching "would require chokidar or similar". Where -#12340's inert fallback at least announced itself at DEBUG, this claimed -success at **INFO**: an operator who set `enabled: true` with `watchPatterns` -and read that line had been told the opposite of the truth. `watchHandles` was -only ever read, deleted, iterated and cleared and **never set**, so -`stopWatching`'s cleanup branch and the teardown loop over its keys were -structurally unreachable rather than merely untaken. `watchPatterns` therefore -had no reader that acted on it — its only two uses were log lines. - -FROM → TO: - -- `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your - globs wherever your own watcher reads them. -- `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`, - called from your own watcher's change handler. That is the debounced - integration point this class does implement, and it is unchanged. - -One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own -file watcher instead of `startWatching` — nothing was ever watched, so nothing -that used to happen stops happening. File watching is the host's job in this -host-driven library; `chokidar` is already a dependency of -`@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — -never of `@objectstack/core` — so a host has a working model to copy. - -The retirement kit: - -- **key tombstone**, and the build is what chose it: the plain deletion was - tried first and `gen:schema` gate (a) refused it, because - `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a - silent strip (#3733, ADR-0104) — the very defect being retired, one layer - down. #12340 could take route 3 because what left there was a whole *def*; a - key leaving a *surviving* def has no such exit. So `watchPatterns` is - `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and - `kernel/HotReloadConfig:watchPatterns` is registered by exact key in - `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves - `authorable-surface` only — the def still emits, so `api-surface` and - `json-schema.manifest` do not. -- **no D2 conversion**, deliberately: the chain walks a normalized stack, and - `HotReloadConfig` is not an authorable surface — no metadata-type binding, - stack collection or manifest embed ever carried it — so a conversion would be - a transform with no seam that ever runs. For the same reason the prescription - carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling - in this module does not. -- **runtime doors** in `@objectstack/core`, because nothing in the tree parses - `HotReloadConfigSchema` outside its own unit test, so the tombstone alone - reaches nobody: `startWatching` now throws an ADR-0112 envelope - (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and - `registerPlugin` refuses a leftover `watchPatterns` the same way — before the - `enabled` check, so a disabled config cannot smuggle the false declaration - through. `startWatching` is kept as a throwing door rather than deleted so - that caller meets a prescription instead of a bare `TypeError`. -- **dead code removed with a firing positive control**: `watchHandles` and both - of its unreachable readers are gone. The zero was pinned first — - `reloadTimers.set` resolves a real writer in the same file and the same scan, - while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the - half that always did something (it cancels a pending debounced reload), and - `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles` - and therefore ran zero times. -- **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a - removal: no runtime composes `HotReloadManager`, so enforcing would build for - a caller that does not exist; and a scan of every planning doc returned zero - mentions of hot-reload file watching against 145 control hits in the same - files, so there is no roadmap for `experimental` to point at. diff --git a/.changeset/human-user-predicate-fail-closed.md b/.changeset/human-user-predicate-fail-closed.md deleted file mode 100644 index 3257f1d594..0000000000 --- a/.changeset/human-user-predicate-fail-closed.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(plugin-security): fail CLOSED on a non-object row in the platform-admin promotion predicate (#12515) - -`bootstrapPlatformAdmin`'s local `isHumanUser` decided "is this `sys_user` row a -HUMAN?" with a bare truthiness check followed by two property comparisons: - -```ts -const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; -``` - -On a truthy NON-object input (`'usr_alice'`, a number, `true`) both comparisons -read `undefined` and therefore both pass, so the input scored **human**. The -same question's consolidated owner — `isHumanUserRow` in `@objectstack/plugin-auth` -— requires `typeof row === 'object'` and answers **non-human** for those inputs. -Two owners of one question, disagreeing, and the disagreement fell the wrong way -on the security-critical side: this is the copy that performs the -**platform-admin promotion**, so it failed OPEN. Its worst shape is the system -account's own id arriving as a bare string, which the old spelling would have -promoted. - -The predicate now mirrors `isHumanUserRow` — the same `typeof` guard, and a real -boolean return instead of echoing a falsy input back: - -```ts -const isHumanUser = (u: any) => - !!u && typeof u === 'object' && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; -``` - -**Why mirroring rather than a stricter rule of its own.** Over-tightening this -predicate has a worse failure mode than the bug: an install that cannot promote -its first admin is locked out of itself. The guard was therefore measured before -it was chosen, not after. Against a real `SqlDriver` over the shipped `SysUser` -declaration, every row a real `sys_user` read yields is a plain object — zero -truthy non-objects, and zero rows whose verdict moves when the guard is added. -The mirrored guard is also already the incumbent on this exact population: -`plugin-auth`'s dev-admin seed filters the byte-identical read (`sys_user`, -`where: {}`, `limit: 50`, system context) through `isHumanUserRow` today. - -**No reachable behaviour changes.** The divergence is unreachable through any -live call site, so this ships as a hardening of malformed-input handling rather -than a behavioural fix. The 14 existing agreement cases in the cross-package -pin are byte-for-byte unmoved; the pin gains the non-object class it previously -had to exclude (it would have failed), which is what now stops the asymmetry -returning — consolidating the two copies into a shared package stays declined, -so nothing else was going to retire it. diff --git a/.changeset/i18n-flow-bucket-liveness-gate.md b/.changeset/i18n-flow-bucket-liveness-gate.md deleted file mode 100644 index 6e12c7b3ec..0000000000 --- a/.changeset/i18n-flow-bucket-liveness-gate.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/lint": patch ---- - -fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624) - -`os lint` computes i18n coverage and runs the authoring-rule registry in a -single pass over the same stack, and for the `flows` translation group the two -halves pointed opposite ways: - -| the author does | which rule fires | what it says | -|---|---|---| -| omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X | -| adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it | - -Measured on one stack, one run: omitting produced **4** `i18n/missing-flow` -findings and 0 liveness findings; authoring produced 0 demands and **2** -`liveness-planned-property` findings ("sets `flows` but this translation -property is planned"). There is no per-rule suppression in `os lint`, only -`--skip-i18n`, which silences the entire `i18n/missing-*` family — so the -author's only escape cost them every other coverage signal. Under -`--i18n-strict` the demand side is an **error**, so a project could be forced -to author keys it is then warned for. - -⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner -reads the group, so a translated wizard string is stored and never shown — the -failure mode `validationMessages` was removed in 17.0.0 for. The premature half -is the demand. - -**The fix.** `collectExpectedEntries` — the single definition of what is -translatable at all, shared by the coverage gate and the `os i18n extract` -skeleton — now leaves out any translation group the liveness ledger warns -authors for authoring. It reads that set from `@objectstack/lint`'s new -`authorWarnedProperties(type)`, which returns the very warn-map -`lintLivenessProperties` iterates, so the demand side and the warn side cannot -drift into disagreeing about the same keys again. - -Two properties fall out of reading the ledger rather than switching on `flows` -by name: the bucket **turns itself back on** the day an objectui screen-flow -runner lands and the row flips to `live` (no flag, no follow-up edit), and any -future group that acquires an `authorWarn` is covered on the day it is marked -rather than re-opening this collision one group at a time. Today `flows` is the -only such group — pinned as an equality so a second one goes red instead of -shipping. - -No other bucket changes: `objects`, `apps`, `pages`, `dashboards`, -`globalActions` and `metadataForms` are all `live` and are reported exactly as -before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` + -`authorWarn: true`. diff --git a/.changeset/i18n-generated-leaf-source-provenance.md b/.changeset/i18n-generated-leaf-source-provenance.md deleted file mode 100644 index 0315927283..0000000000 --- a/.changeset/i18n-generated-leaf-source-provenance.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/cli": minor ---- - -feat(platform-objects,cli): record which source revision a generated translation leaf was filled from (#11671) - -Closes the half of the sticky-translation-drift class that no value comparison -could reach, under maintainer ruling #12069 Option A — by extending the existing -#8765 Option B source-hash mechanism to the generated bundles rather than -building a second one. - -**The hole.** `os i18n extract --fill=default` fills gaps only: any non-empty -value in a translated locale wins forever. So the ordinary sequence — extract, -revise the source string, extract again — rewrites `en` and strands the previous -source text in every other locale. The bundle is still in sync by key, so -`check:i18n` reports OK; the leaf is still present, so `check:i18n-coverage` -counts it translated. Measured on #11659 at `bbe0b17`: three locales serving a -602-char superseded draft of a 411-char help string under 31 green checks. Once -the source has moved, that stale fill is indistinguishable **by value** from a -real translation — 2648 of 3010 leaves differ from `en`, so "untranslated AND -differing from the source" describes an empty set, not a noisy one. - -**What is new.** `os i18n extract --source-hashes` writes -`.source-hashes.generated.ts` beside each generated bundle: per leaf, -the digest of the source revision that leaf is **still a byte copy of**. -`withSourceFallback` takes that table as a fourth argument and now judges the -`objects` / `metadataForms` sections as well as the hand-authored ones, so a -leaf whose source has moved underneath it serves the current source string -instead of a superseded draft — the same degradation an untranslated key already -produces, which is the invariance the #8765 ruling turned on. - -The generated half needs one conjunct the hand-authored half does not: the leaf -must still hold the recorded bytes. Its hash table is itself generated, so a -translator cannot be asked to refresh a digest by hand the way -`.source-hashes.ts` asks; without that conjunct, re-translating a stale -leaf would leave the old record standing and report the fresh translation as -stale forever. With it, editing the value clears the flag by itself. - -**Behaviour on the day it lands: unchanged for every leaf.** Records are -written only where a leaf is currently a byte copy of the **current** source, so -every record equals the current digest and nothing is stale. Measured across the -nine bundle sets: 9030 translated leaves, 1543 byte-equal to `en` (records -written), 7487 differing (left with no record — legacy-trusted, per the ruling's -property 1, since nothing in the tree says which revision they were made from). -No committed bundle changed a byte. - -**Scope.** `--source-hashes` is off by default and `@objectstack/platform-objects` -is the one bundle set that opts in, by documenting the flag in its extract -config. The other eight sets keep exactly today's behaviour and can be enabled -file-by-file later; a set with no companion is entirely legacy-trusted. - -The false "this hole cannot occur there" note that kept the generated sections -out of the mechanism is corrected in `source-hash.ts`, with the measurement that -falsifies it. diff --git a/.changeset/i18n-provenance-served-in-eight-sets.md b/.changeset/i18n-provenance-served-in-eight-sets.md deleted file mode 100644 index d13a708c80..0000000000 --- a/.changeset/i18n-provenance-served-in-eight-sets.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch -"@objectstack/plugin-audit": patch -"@objectstack/plugin-security": patch -"@objectstack/plugin-sharing": patch -"@objectstack/plugin-webhooks": patch -"@objectstack/service-messaging": patch -"@objectstack/service-realtime": patch -"@objectstack/service-storage": patch ---- - -fix(i18n): read the provenance companion at serving time, not only record it (#12642) - -Maintainer ruling #12069 Option A (#11671) landed translation provenance as -**two** halves: `os i18n extract --source-hashes` RECORDS which source revision -a generated leaf is still a byte copy of, and `withSourceFallback` READS those -records at serving time and substitutes the current source for a leaf whose -source has moved underneath it. The recording half was then rolled out to every -bundle set. The reading half was not — measured on `main`: provenance -**recorded in 9 of 9** bundle sets and **read at serving time in 1**. - -The other eight assembled their `TranslationBundle` straight from the raw -generated modules and never consulted the companion sitting beside them, so -they recorded the drift and went on serving the superseded draft. Nothing said -so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` -counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale -rule needs a SECOND locale holding the same stale bytes before it can testify. -The measured case had one locale and no second witness. - -All eight are wired here, in the shape `@objectstack/platform-objects`'s own -`metadata-translations/index.ts` uses — the committed -`.source-hashes.generated.ts` passed as the fourth argument, the third -left `undefined` because these sets have no hand-authored sections. Provenance -is now recorded in 9 of 9 sets and served in 9 of 9. - -`@objectstack/plugin-webhooks` was the last of them and is the only one whose -manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, -which that package did not declare. It was **already in that package's install -closure** through `@objectstack/service-messaging`, so the edge declares a -resolution that already resolved rather than adding a package to the graph — -and relying on it undeclared would have been a phantom dependency under this -repo's strict package manager. - -`check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this -cannot silently come apart again: a bundle set that commits a companion and -does not consult it at serving time now fails the build, including a tenth set -that lands tomorrow. - -**Graded `patch`, and the grade is the interesting part.** No API changes, no -new exported surface, and no key set moves — substitution was chosen over -deletion precisely so key-set claims stay put (ruling #8765 Option B). What -changes is which STRING a stale leaf serves. On this tree that is **zero -leaves**: a record is only ever written for a leaf that IS a byte copy of the -current source, so the companions arrive 0-stale by construction. The change is -in what happens the next time a source string moves — the reader sees the -English source rather than a superseded draft of it, which is the same -degradation an untranslated key already produces and not a new state. diff --git a/.changeset/i18n-source-hash-rollout-eight-sets.md b/.changeset/i18n-source-hash-rollout-eight-sets.md deleted file mode 100644 index 7f17e38131..0000000000 --- a/.changeset/i18n-source-hash-rollout-eight-sets.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/plugin-approvals": patch -"@objectstack/plugin-audit": patch -"@objectstack/plugin-security": patch -"@objectstack/plugin-sharing": patch -"@objectstack/plugin-webhooks": patch -"@objectstack/service-messaging": patch -"@objectstack/service-realtime": patch -"@objectstack/service-storage": patch ---- - -chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) - -`os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) -records, per generated translation leaf, the digest of the source revision that -leaf is **still a byte copy of** — the one signal that tells a stale fill from a -real translation once the source has moved and the two stopped being -distinguishable by value. It shipped opt-in, and exactly one of the nine i18n -bundle sets opted in. A landed detector, a changeset announcing it and a green -gate read together as *"generated translation staleness is now caught"*; for -eight of nine sets it was not, and the thing making it not caught was a single -absent flag in an extract config — invisible from all three of those surfaces. - -**All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, -`plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, -`service-realtime`, `service-storage`. Each documents `source-hashes` in its -extract config and commits three `.source-hashes.generated.ts` -companions, produced by the same extract run as the bundles they sit beside -(`check:i18n` compares them byte-for-byte, so they cannot be written by hand). -`check:i18n` now reports 7 bundles per set where it reported 4, and 11 for -`platform-objects` where it reported 8. - -**Records count what is currently RECORDABLE, never what is covered.** A record -is written only for a leaf that *is* right now a byte copy of the current -source, so a fully translated locale starts with an empty table — which is the -instrument armed, not an instrument that measures nothing: the entry appears by -itself on the first extract after a leaf becomes a fill. Measured at this -commit, per set over its three translated locales: `service-messaging` 289, -`plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, -`plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and -ja-JP are fully translated and start empty), `service-realtime` 0 (all three -locales fully translated). **419 records written across the eight sets, 0 -stale.** - -**One extractor fix the rollout forced.** `--source-hashes` had one user, and -that user commits both generated sections, so the interaction with -`--no-metadata-forms` had never been exercised. The provenance table is computed -over every generated section the extractor builds; the eight sets here commit no -metadata-forms bundle, and their `metadataForms` subtree — absent from their -merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf -of it was recordable. First measured on `plugin-audit`: **763 records, of which -2 were its own objects and 761 were digests of the Studio metadata-form baseline -`@objectstack/platform-objects` owns.** Those records are unreadable in the -package holding them and would have rewritten all 24 companions on any unrelated -`*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 -and every `bundle-ownership.test.ts` keep out of committed bundles. The -companion now covers exactly the sections a run commits, decided by the same two -predicates that decide the bundle files. `platform-objects` commits both, so its -three committed companions are byte-for-byte unchanged. - -**Grade: `patch`, and behaviour on the day it lands is unchanged for every -leaf.** A record is written only where a leaf is currently a byte copy of the -**current** source, so every record written equals the current digest and none -of them can be stale; the mechanism cannot arrive red. No committed translation -bundle changed a byte, no public API moved, and no leaf's rendered text changed. -`narrowToCommittedSections` is new but internal to `@objectstack/cli` — the -package's entrypoint does not re-export the extractor utils. - -**What this does not do**, stated so the boundary is not inferred wrongly a -second time: these eight sets now *record* provenance. Reading it at serving -time is `withSourceFallback`, and that is still wired in -`@objectstack/platform-objects` alone — so a stale fill in one of the eight is -now recorded and reportable, but not yet substituted at runtime. Tracked -separately. diff --git a/.changeset/identifier-ceiling-storage-owned.md b/.changeset/identifier-ceiling-storage-owned.md deleted file mode 100644 index e21ba23c4f..0000000000 --- a/.changeset/identifier-ceiling-storage-owned.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): state that identifier length ceilings are storage-owned (#12144) - -The shared identifier schemas (`SystemIdentifierSchema`, -`SnakeCaseIdentifierSchema`, `EventNameSchema`) declare `.min()` plus a -grammar and no `.max()`. That absence is now a documented contract decision -rather than an accident: the enforced ceiling on an identifier is the -`maxLength` of the column that stores it (refused at the write seam by -ObjectQL's record validator), and the storing columns disagree — 100 for -`sys_permission_set.name` / `sys_position.name` / `sys_capability.name`, -255 for `sys_metadata.name` — so no single shared `.max()` can equal every -consumer's enforced ceiling. No accepted value changes. - -A pin test in `@objectstack/plugin-security` -(`identifier-storage-ceiling-pin.test.ts`) links the spec schemas to the -storage columns that bound them, reading the widths off the registration -surface (the PR #12143 idiom), so the two cannot drift silently: a `.max()` -landing below a storing column's width, or a column width change, turns a -named test red with re-derivation instructions. diff --git a/.changeset/identity-keyed-text-bounds.md b/.changeset/identity-keyed-text-bounds.md deleted file mode 100644 index 3b2e40240e..0000000000 --- a/.changeset/identity-keyed-text-bounds.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -Declare sourced `maxLength` bounds on the thirteen unbounded keyed identity -columns, so their declared indexes can exist on MySQL - -`driver-sql` (since #11430) honours a keyed text-family field's declared -`maxLength`, emitting `varchar(maxLength)` instead of `TEXT` — but thirteen -identity columns declared no bound at all, so on MySQL every one of their -declared indexes was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB -column cannot be a key without a prefix length) and the objects landed -registered-but-broken. Measured on live MySQL 8.0.46 (`+08:00`, -`STRICT_TRANS_TABLES`): schema-sync failures drop **12/44 → 8/44** platform -objects and physically-present declared indexes rise **89/128 → 104/128**, -with the Postgres 16 control at 0 failures on both legs. `sys_session`, -`sys_api_key`, `sys_device_code` and `sys_oauth_consent` sync completely -clean — a MySQL stack can now enforce the session-token uniqueness its -sign-in path assumes. - -Every bound is derived from a named source, none guessed (maintainer ruling -on #11374, 2026-08-24 — route A; the full table with sources is in the PR): -better-auth 1.7.1's own MySQL schema mapping (`session.token` / -`verification.identifier` → 255), its device-authorization plugin's hard -runtime cap of 191 on both codes, IdP norms (`account_id` 256 = SAML Core -NameID cap, above OIDC Core's 255 `sub` cap), the landed bounds of referenced -or producing siblings (`client_id` × 4 → 255 from -`sys_oauth_application.client_id`; `provider_id` 255 from -`sys_sso_provider.provider_id`; `issuer` 2048 from `sys_sso_provider.issuer`), -and the in-repo producer (`sys_api_key.key` 64 = fixed sha-256 hex). - -This is an enforcement change on published objects — hence the minor grade: a -write wider than its column's new bound is now **refused** (measured: a -300-char `sys_session.token` insert fails `ER_DATA_TOO_LONG` on a strict -server, 0 rows; a 255-char one lands). Every bound admits everything its -upstream producer can write, so only values the producing contracts already -forbid are affected. - -Deliberately not bounded, per the ruling's escape clause: -`sys_verification.value` (better-auth's oauth-provider stores JSON -authorization-code payloads there — no defensible bound exists), and -`sys_import_job.created_by` (outside this card's identity surface). -`sys_account.issuer`'s 2048 exceeds the 768-char utf8mb4 key ceiling on -purpose — tighter would refuse SSO sign-ins that `sys_sso_provider`'s own -contract admits — so its `(issuer, account_id)` unique stays for #11627's -hash-shadow route, alongside the `maxLength: 1024` token columns. A new pin -test enumerates every keyed text-family identity column and names any future -unbounded arrival. diff --git a/.changeset/import-job-created-by-bound.md b/.changeset/import-job-created-by-bound.md deleted file mode 100644 index 6db347e9e1..0000000000 --- a/.changeset/import-job-created-by-bound.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -Declare a sourced `maxLength` on `sys_import_job.created_by`, so its declared -index can exist on MySQL — route A's last column - -`driver-sql` (since #11430) honours a keyed text-family field's declared -`maxLength`, emitting `varchar(maxLength)` instead of `TEXT`, and #11699 -declared bounds on thirteen keyed identity columns. `sys_import_job.created_by` -is keyed by `(created_by, created_at)` and declared no bound at all, so on MySQL -that index was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB column cannot -be a key without a prefix length) and the object landed registered-but-broken. -It was the only remaining such object outside the >768-character class that -#11627 tracks. - -The bound is **255**, derived by referenced-column transitivity rather than -chosen: the column holds a `sys_user.id` stamped by the rest-server import route -from `context.userId`, and `driver-sql` creates every table's primary key as -`table.string('id').primary()` — knex's `varchar(255)` — so no id this column -can receive exceeds 255. It agrees with what the column would get if declared -like its siblings (`Field.lookup('sys_user')` emits -`DEFAULT_STRING_VARCHAR_CHARS` = 255) and with the landed declarations for the -same value class (`sys_metadata_audit.actor`, `sys_metadata_commit.actor`, -`sys_view_definition.owner`, all 255). A minted platform id is 26 characters, so -the bound clears the floor with 229 characters of headroom. - -This is behaviour-narrowing on a published object: on a strict MySQL server a -`created_by` longer than 255 is now **refused** (`ER_DATA_TOO_LONG`, 0 rows) -rather than stored, where previously the column was unbounded `TEXT`. No value -the producing contract can emit is affected, because the id it copies is itself -capped at 255 by its own column. - -The route-A pin moves from `identity/identity-keyed-text-bounds.test.ts` to -`platform-keyed-text-bounds.test.ts` and now enumerates **every** platform -object the package exports, not just `identity/`. That directory scoping is -exactly how this column escaped the first pass — the pin could not see it — and -a new control asserts the enumeration reaches columns in `audit/`, `metadata/` -and `system/` so the narrowing cannot silently return. diff --git a/.changeset/import-mapping-name-declared.md b/.changeset/import-mapping-name-declared.md deleted file mode 100644 index 7f307f2e32..0000000000 --- a/.changeset/import-mapping-name-declared.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Declare `mappingName` on `ImportRequestSchema` (and therefore on the aliased -`CreateImportJobRequestSchema`). Both import routes already accepted it on the -wire — `prepareImportRequest` resolves the named `mapping` artifact and refuses -`mappingName` plus an inline `mapping` with `400 CONFLICTING_MAPPING` — but the -published contract could not express it, so the typed SDK call -`client.data.import(object, { mappingName: '…' })` was a TS2353 compile error -(#10330). The key is now declared with the same mutual exclusion as a schema -`.refine()`, so a conflicting pair is rejected at authoring time as well as by -the route. Additive only: the schema is a plain `z.object` that strips unknown -keys, so no existing caller changes behavior. diff --git a/.changeset/import-object-name-override-namespace-prefix.md b/.changeset/import-object-name-override-namespace-prefix.md deleted file mode 100644 index 221d8d8320..0000000000 --- a/.changeset/import-object-name-override-namespace-prefix.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -`importObject` now refuses an explicit `opts.name` that violates the ADR-0028 -namespace-prefix rule (#11061). The override used to be taken verbatim -(`opts.name ?? draft.name`) and persisted through `metadata.register('object', -…)` — the one runtime write path no namespace gate looks at — so -`POST /api/v1/datasources/:name/external/tables/:remote/import` with -`{"name": "customers"}` minted an unprefixed federated object that -`defineStack()` and the publish pre-flight would both have refused. - -The refusal answers `400 EXTERNAL_IMPORT_ERROR` (the family's registered -ADR-0112 code, in the #8016 thrown-refusal shape) carrying -`validateObjectNamespacePrefix`'s own actionable message — the same text the -publish gate serves for the identical violation, e.g. `Object 'customers' is -missing the package namespace prefix. Rename it to 'wh_customers' (namespace = -'wh').` A compliant override (`wh_customers`), a `sys_*` platform-reserved -name, and any override on a datasource whose package resolves no namespace are -accepted exactly as before; the derived-name path (no `name` in the body) is -unchanged. diff --git a/.changeset/ingress-resolved-id-wins-over-payload-id.md b/.changeset/ingress-resolved-id-wins-over-payload-id.md deleted file mode 100644 index 09316d043c..0000000000 --- a/.changeset/ingress-resolved-id-wins-over-payload-id.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/service-settings': patch -'@objectstack/metadata': patch ---- - -Make the settings engine facade and the metadata database loader bind the row -they resolved, not a row the payload names - -Two ingresses resolved an authoritative row id and then folded it into the -write payload with the **losing** spread order — `{ id, ...data }` — so a -caller-supplied `data.id` spread over the id the ingress had just resolved and -silently retargeted the write: - -- `wrapEngineAsSettingsEngine`'s by-id `update` branch - (`@objectstack/service-settings`), whose id comes from the caller's - `where.id`. -- `DatabaseLoader._update` (`@objectstack/metadata`), whose id arrives as a - separate parameter every caller resolves first (`existing.id`, from the read - immediately above). - -Both now spell it `{ ...data, id }` — the operation's id **after** the spread, -so it wins. That is the convention the repo's other two ingresses already -document: `rest-server.ts`'s batch update arm ("the operation's id AFTER the -spread, so it wins") and `protocol.updateData`'s #6479 fix -(`{ ...request.data, id: request.id }`). - -**No wrong write is known to have been reachable.** Both sites' current callers -build fresh field literals and never put an `id` inside `data`, so this is -hardening a fragile pattern rather than repairing a measured defect. What makes -it worth the three characters is that neither site can be caught downstream: -both pass **no `where`** to the engine, so the payload is the only id the engine -ever sees, and the engine's conflicting-id refusal (`UPDATE_ID_MISMATCH`, 400) -needs two disagreeing declarations before it can fire. The fold is the entire -trust boundary at both sites, and it is one refactor — a caller handing back a -row copy, and rows carry `id` — from the #6479 shape. - -Both are pinned with a payload whose `id` names a **different** row than the -one the ingress resolved, asserting the resolved row is still the row bound. A -pin exercising a payload without an `id` would have passed against both -spellings. The doubles answer "which row does this bind?" with the producer's -own `assertEngineUpdateDispatch`, so they cannot be kinder about it than a -running server. diff --git a/.changeset/init-scaffold-object-file-spelling.md b/.changeset/init-scaffold-object-file-spelling.md deleted file mode 100644 index e68ba91295..0000000000 --- a/.changeset/init-scaffold-object-file-spelling.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os init` scaffolds its starter object as `_item.object.ts`, the spelling the registry declares (#11598) - -`objectstack init` wrote its starter object to `src/objects/_item.ts` -while `DEFAULT_METADATA_TYPE_REGISTRY` declares the `object` type as -`**/*.object.ts` / `.yml` / `.json`. Measured with `node:path`'s `matchesGlob` -against the registry read at runtime: `src/objects/my_app_item.ts` matched -**zero** of the three patterns, `src/objects/my_app_item.object.ts` matches -exactly one. Both `srcFiles` tables (the `app` and `plugin` templates) and the -barrel specifier they emit now carry the type infix. - -**This was a naming inconsistency, not breakage — measured, not assumed.** A -scaffolded project declares its objects in code (`import * as objects from -'./src/objects'` → `objects: Object.values(objects)`), so the object reaches the -stack through the barrel's *module specifier*, and `os dev` / `os serve` then -boot from the compiled `dist/objectstack.json` rather than by globbing source. -Three real scaffolds were compiled with the real `os compile` to establish it: -the old-spelled file **did** land in the artifact (so nothing was ever silently -skipped — this is not the #10359 silent-strip shape), a `*.object.ts` file -dropped into `src/objects/` but *not* re-exported from the barrel did **not** -land in it (so the registry glob was never on this load path), and the new -spelling lands identically. - -What it *was*: one CLI teaching two spellings for one metadata type. After -#11071 an author who runs `os init` and then `os g object customer` gets -`src/objects/my_app_item.ts` beside `src/objects/customer.object.ts` in the same -directory, from the same CLI. The registry spelling is the authority — the same -convergence #11071 settled — and it is already what `create-objectstack`'s blank -starter ships (`note.object.ts`), what the examples use -(`app-crm/src/objects/account.object.ts`), and what the getting-started docs -list as the house convention two lines under the callout that described the old -name. - -**Existing scaffolded projects need to do nothing.** The old filename still -loads exactly as it did — the barrel imports it by specifier and the filename is -not consulted. Renaming `src/objects/_item.ts` to -`src/objects/_item.object.ts` (and the matching `from './_item'` → -`from './_item.object'` in `src/objects/index.ts`) is an optional -consistency cleanup, not a migration: it changes nothing about how the project -builds, boots or behaves. Only newly scaffolded projects get the new name. diff --git a/.changeset/introspection-contract-withdraw-indexes-widen-defaults.md b/.changeset/introspection-contract-withdraw-indexes-widen-defaults.md deleted file mode 100644 index 5d10c236df..0000000000 --- a/.changeset/introspection-contract-withdraw-indexes-widen-defaults.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/driver-sql": patch -"@objectstack/objectql": patch ---- - -Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two -introspection declarations to the measured emitted types (#11122, maintainer -ruling 2026-08-23, option B — 「其他同意你的意见」). - -The spec's introspection contract (`schema-diff-service.ts`) declared -`indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted -it — a consumer typed against the promise read `undefined` with no compiler -complaint. It also declared `defaultValue?: string` while the in-tree SQL -driver passes `knex.columnInfo().defaultValue` through raw (measured on live -SQLite: `null` for a column with no default, dialect-quoted strings such as -`'abc'` otherwise; other producers report native values such as `true`). - -- `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: - an absent key means the producer did not read indexes; an empty array is a - positive claim the table HAS none. Producers that did not look must omit - the key rather than emit `[]`. Wiring the index read into - `introspectSchema()` is explicitly NOT part of this change. -- `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow - before use instead of trusting a string promise no producer kept. -- The SQL layer's extra `maxLength` fact (driver-sql / objectql - `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to - `number | string` — SQLite reports the string `"255"` where other dialects - report a number. - -With the spec now telling the truth, the deliberate `Omit` workarounds in -`@objectstack/driver-sql` and `@objectstack/objectql` (which carved -`defaultValue` and `indexes` out of the spec types to keep the divergence -visible) are retired: both packages' introspection types now extend the spec -contract directly. - -Consumers that read `table.indexes` must guard for absence (none exist -in-tree — the requirement was never honored, so today's readers would have -crashed on `undefined` anyway); consumers of `defaultValue` must narrow from -`unknown` before string operations. diff --git a/.changeset/invitation-probe-page-ceiling.md b/.changeset/invitation-probe-page-ceiling.md deleted file mode 100644 index ec445aaafc..0000000000 --- a/.changeset/invitation-probe-page-ceiling.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations (#11770) - -Under the `invite_only` audience posture that #11739 made the default, an -administrator could send an invitation and the invitee's account creation would -still be refused with `SELF_REGISTRATION_CLOSED` — silently, with no signal to -either party — as soon as the environment held more than 200 concurrently -pending invitations. A 500-employee onboarding is an ordinary way to reach that. - -`AuthManager.hasPendingInvitationFor` answered "does this address hold a pending -invitation?" by reading at most 200 rows filtered only on `status = 'pending'` -and scanning them in memory for a case-insensitive email match. Past the first -page the invitee simply was not there, so the fail-closed `catch`-alike branch -applied the posture and refused a legitimate invitee. - -The address now goes into the query — `sys_invitation.email` carries a declared -index — and the page chain is exhausted, so no row count can hide a live -invitation. A page is "pending invitations addressed to this one person", which -better-auth bounds by refusing a second pending invitation per organization, so -this is not a read of the environment's pending population on the sign-up path; -in practice it is a single indexed lookup where the old code always read 200 -rows. - -The in-memory scan existed on the stated belief that invitation addresses are -stored as the inviter typed them. Measured against the installed better-auth -1.7.1, that is wrong on both halves — `organization/invite-member` lowercases -the address before storing it, and `internalAdapter.createUser` lowercases the -registrant's before calling `validateUserInfo` — and the vendor's own -`findPendingInvitation` / `listUserInvitations` / `findMemberByEmail` all query -with `email.toLowerCase()`, so a mixed-case row was never redeemable through -`accept-invitation` anyway. - -The row-side comparison is kept rather than deleted: `=` folds case on some -collations and folds accents with it, so every returned row is re-checked -against the normalized address — a case-only difference still matches, an -accent-only difference does not. Expiry stays in JS so a row with no readable -`expires_at` keeps reading as live. The security properties are unchanged: -`status = 'pending'` only, expiry still enforced, and an unanswerable probe -still means no carve-out. diff --git a/.changeset/invite-entry-on-members-tab.md b/.changeset/invite-entry-on-members-tab.md deleted file mode 100644 index cf2bebfe71..0000000000 --- a/.changeset/invite-entry-on-members-tab.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/spec': patch ---- - -Surface the email-invite entry on the organization record page's default -Members tab, and stop it rendering as a twin of "Add Member" - -The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on -tab-0 **Members**, whose related-list toolbar carried exactly one action — -`add_member`, which attaches an **already-registered** user by id. The -email-invite entry, `invite_user`, was declared only on `sys_invitation` and -`sys_user`, so it appeared only on tab-1 Invitations. An admin looking to -"invite a teammate by email" landed on Members, found no invite affordance and -concluded the product had none. The delivery half worked the whole time -(`sendInvitationEmail`, template `auth.invitation`) — only the door was in -another room. - -`sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of -`add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same -email + role inputs, and the same `requiresFeature: 'organization'` capability -gate as the other two mirrors. Declaration order is render order in the -related-list toolbar bridge, so the invite button sits left of the attach one. - -**The `email` param names `objectOverride: 'sys_invitation'`, and must.** -`sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` -declaration would leave the param unresolvable — the renderer answers that with -a `type: 'text'` fallback labelled by the raw field name, which still submits -and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no -override: `sys_member` declares it, from the same -`BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now -holds this over **all three** mirrors, so the next copy of any action cannot -reintroduce the shape. - -`add_member` keeps its behaviour and its label and is differentiated only in -chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing -record" icon `sys_account`'s `link_social` already uses) — so the two buttons -no longer render as identical primary `user-plus` twins. Both halves are -honoured by the renderer: it draws `primary` filled and every other variant -outlined. - -The `@objectstack/spec` half is one line of registry bookkeeping: -`PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as -it already books the other twelve. No schema, export or authorable key changes. diff --git a/.changeset/invoked-as-reciprocal-pointers.md b/.changeset/invoked-as-reciprocal-pointers.md deleted file mode 100644 index a22d3ca023..0000000000 --- a/.changeset/invoked-as-reciprocal-pointers.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): `invocation.ts`'s `isProcessEntry` doc now names its two siblings - -The three-copy `argv[1]`-vs-`import.meta.url` predicate (`isProcessEntry` here, -`isEntrypoint`/`invokedAs` in this repo's `scripts/invoked-as.mjs`, and -objectui's own `scripts/invoked-as.mjs`) carried the "change one, change the -others" sync obligation in only one of the three copies — objectui's. Neither -objectstack copy pointed at the other two, so an agent editing either file -here had no way to discover that a third copy exists elsewhere (#12013). - -Both objectstack copies were otherwise correct and are not changed in -substance; only a reciprocal pointer is added to each, comment-only. diff --git a/.changeset/ipubsub-delivery-guarantee-docblock.md b/.changeset/ipubsub-delivery-guarantee-docblock.md deleted file mode 100644 index 4892f1ca59..0000000000 --- a/.changeset/ipubsub-delivery-guarantee-docblock.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): state `IPubSub`'s real delivery guarantee on the interface docblock (#12651) - -The `IPubSub` contract docblock claimed **"At-least-once delivery; handlers MUST -be idempotent"**. No shipped driver provides that, and the repo's own measured -statements said so elsewhere: `content/docs/kernel/cluster.mdx` §4.2 ("**No -shipped driver provides this yet.**"), `@objectstack/service-cluster-redis`'s -`publish` docblock (plain Redis pub/sub — at-most-once, fire-and-forget, no -persistence, "acceptable **only** for events that are pure cache-invalidation -hints, never the source of truth"), and the `authz.invalidated` channel module -in `@objectstack/core`, which recorded the contradiction inline rather than -resolving it in the wrong direction. - -The interface docblock is the load-bearing one for the hazard it invites: it is -what a consumer's editor shows at the call site, before they write a subscriber. -"At-least-once" tells that author their only obligation is to tolerate -**duplicates**, while the transport's actual failure mode is the opposite — a -**lost** message, with no replay and no upper bound on how long a node that was -down at publish time stays wrong. An author who designs for duplicates and not -for loss has designed for the wrong hazard, and neither the type nor the tests -contradict them. - -The docblock now states the shipped guarantee **driver-relatively** — delivery -is whatever the configured driver declares, and no shipped driver exceeds -at-most-once — so a future durable driver lands without rewriting the contract. -It says a missed message is expected, requires handlers to be idempotent **and** -loss-tolerant, puts the staleness bound outside the bus (a TTL, a reload, a -durable outbox of its own), and points at `deliverySemantics` as the per-channel -surface while naming what that surface is: what a channel *asks* for, not what -it gets. The existing driver-reach sentence (memory = synchronous in-process; -remote drivers cross nodes) is unchanged — it was never part of the false claim. - -Prose only. No schema, no type, no accept/reject behaviour changes; the shipped -`.d.ts` carries the corrected text to consumers' editors, which is the whole -point of the repair. diff --git a/.changeset/ipubsub-docblock-staleness-retire.md b/.changeset/ipubsub-docblock-staleness-retire.md deleted file mode 100644 index 7678d9bc05..0000000000 --- a/.changeset/ipubsub-docblock-staleness-retire.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/core": patch -"@objectstack/service-cluster": patch ---- - -docs(core,service-cluster): retire the two docblocks left stale by `IPubSub`'s corrected delivery guarantee (#12836) - -#12651 corrected `IPubSub`'s contract docblock: delivery is whatever the -configured driver declares, no shipped driver exceeds at-most-once, a missed -message is EXPECTED, and handlers must be idempotent **and** tolerate loss. -Two docblocks elsewhere still described the world before that correction. - -**`@objectstack/core` — `security/authz-invalidation-channel.ts`.** It carried a -paragraph asserting, in the present tense, that the interface docblock "still -says" *At-least-once delivery*, and that repairing it was a `packages/spec` -change filed separately. That filing was #12651 and it has landed, so the -paragraph is now false rather than merely stale — it sends the next reader -looking for a live disagreement between the interface and the drivers that no -longer exists. Replaced with a plain pointer to the interface docblock. -Everything else in that docblock is unchanged: the at-most-once reasoning, the -TTL-is-the-bound rule, and the best-effort-at-the-publish-site note all still -hold. - -**`@objectstack/service-cluster` — `memory/pubsub.ts`.** The line "At-least-once -semantics held vacuously (a single in-process delivery)" was wrong on its own -terms even before #12651: the same docblock states that handler errors are -swallowed and logged via `onError`, so a handler that throws loses the message -with no retry and no persistence. That is not at-least-once in any sense, and -"vacuously" does not save it. Replaced with the honest statement — one -synchronous in-process delivery attempt per subscriber, no persistence, no -retry, no replay. - -Prose only. No behaviour change, and no test changed. diff --git a/.changeset/ja-jp-webhook-label-consistency.md b/.changeset/ja-jp-webhook-label-consistency.md deleted file mode 100644 index 6d157116a9..0000000000 --- a/.changeset/ja-jp-webhook-label-consistency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/plugin-webhooks': patch ---- - -Fixed the `sys_webhook` object's `ja-JP` display name (`label`) to ウェブフック, matching the already-Japanese `pluralLabel` and the bundle's own help prose (`object_name.help`, `triggers.help`), which both use the same term. Previously `label` was a stale `Webhook` fill left over from a prior source revision, so the object rendered its own name two different ways within the same locale in the Setup/admin UI. `description` (which uses "Webhook" inside a longer Japanese sentence) and the `Webhook ID` field label are unchanged — this only renames the object itself. `zh-CN` is deliberately untouched: it uses `Webhook` for both slots on purpose, as there is no established Chinese rendering of the term in this codebase. diff --git a/.changeset/kernel-context-preview-mode-retired.md b/.changeset/kernel-context-preview-mode-retired.md deleted file mode 100644 index 2c894eaf71..0000000000 --- a/.changeset/kernel-context-preview-mode-retired.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire preview mode — the `'preview'` RuntimeMode value and the whole `KernelContext.previewMode` / `PreviewModeConfig` block (#11846, ADR-0049) - - - -**BREAKING** accept-set narrowing and export removal, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the -prescription is registered under protocol major 18 — -`RETIRED_KEYS_BY_MAJOR[18]` for both walked-shape keys, -`RETIRED_DEFS_BY_MAJOR[18]` for the def, plus the D3 semantic entry -`kernel-context-preview-mode-retired` — where `os migrate meta` users will -look). - -The declaration was the sharpest declared-≠-enforced shape on a SECURITY -surface: the schema promised "bypass auth, simulate admin identity" and named -a production guard "the runtime must enforce", and NO code path implemented -either half. Measured zero consumers in objectstack, objectui and cloud -(cloud#1651, closed 2026-08-26 with positive controls: `RuntimeMode` has zero -hits repo-wide there, and `ArtifactKernelFactory` — where preview auto-login -would live if anywhere — has 20+ hits and never touches `previewMode`; -re-verified in objectstack at dispatch, 2026-08-27). An author — very often -an AI — could write the six-key block per the reference docs, parse cleanly, -and get no behaviour and no diagnostic. - -FROM → TO: - -- `mode: 'preview'` → *(removed value)* — `mode` defaults to `'production'`; - use `'development'` for local demo work. The rejection carries the - prescription via the enum's own error map (the `HookBodyCapability` - precedent); every other mode keeps zod's own message. -- `previewMode: { … }` on `KernelContext` / `TenantRuntimeContext` → - *(removed key)* — tombstoned with `retiredKey()` (the schemas are not - `.strict()`, so a bare deletion would be a silent strip): authoring it is - now a `tsc` error and a parse error carrying the prescription. -- `PreviewModeConfigSchema` / `PreviewModeConfig` / `PreviewModeConfigParsed` - → *(removed — no replacement)*. The def described behaviour no layer - implemented; an exported value schema with no consumer reads as a - capability (#3950). - -One-line fix: delete the key and the value — neither ever changed runtime -behaviour, so removing them changes nothing observable. Preview deployment -ROUTING is untouched: `OS_PREVIEW_MODE` / `OS_PREVIEW_BASE_DOMAINS` keep -working exactly as documented (deployment routing, never identity). If a -preview experience becomes a product capability it re-declares fresh, with -the production-posture hard-refusal as the first-landed half (#11846 ruling -record, maintainer 2026-08-27). - -The retirement kit: - -- tombstones at both declarations (`kernel/KernelContext:previewMode` and the - `.extend()` copy `kernel/TenantRuntimeContext:previewMode`, both in - `RETIRED_KEYS_BY_MAJOR[18]`); the enum value's prescription on - `RuntimeMode`'s error map (enum-VALUE retirements register nothing in - RETIRED_KEYS_BY_MAJOR and leave the surface ratchets byte-identical) -- whole-def deletion `kernel/PreviewModeConfig` in `RETIRED_DEFS_BY_MAJOR[18]` - (manifest key deliberately removed; the #4725 gate adjudicated it) -- deliberately NO D2 conversion: a kernel context is constructed by host code - at boot — not a stack collection member, never a `sys_metadata` row — so - the conversion chain has no seam that would ever see one (the - `kernel/Manifest:loading` disposition); the D3 semantic entry carries the - prescription -- pin tests (`kernel/preview-mode-retirement.test.ts`): both rejection sites - flip from silent parse to the prescription; zero holders for all 3 retired - export names on every public entry; the carrier schemas survive diff --git a/.changeset/khaki-donuts-refuse.md b/.changeset/khaki-donuts-refuse.md deleted file mode 100644 index c9343a9a92..0000000000 --- a/.changeset/khaki-donuts-refuse.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/driver-mongodb': patch ---- - -`driver-mongodb` refuses an aggregate function it does not lower, instead of -answering it as a silent SUM (#12818). - -`buildAccumulator`'s `switch` on `agg.function` ended with -`default: return { $sum: fieldRef ?? 0 }`, so ANY name this driver does not -lower — a typo (`median`), a miscased spelling (`COUNT_DISTINCT`), a function -added to the contract but not to this file, or an unnarrowed `method` arriving -from `StrategyContext.executeAggregate` (#12776) — was answered as a **sum of -that column**, under the alias the caller asked for, with no error, no envelope -and no log. It is the worst available answer precisely because it is -arithmetically plausible: a dashboard tile renders the number without complaint, -so nothing downstream can tell "your function ran" from "your function was -silently replaced". The field-less spelling was quieter still — `{ $sum: 0 }`, -i.e. `0`, which reads as "no matching rows". - -The refusal is the two-class ADR-0112 envelope both SQL faces already answer -with (#5907), first sentence for first sentence, so one condition cannot have -two wire identities depending on which backend served it: - -- a name the Query Protocol does not declare answers `INVALID_QUERY` / **400** - and names the declared vocabulary (`@objectstack/spec AggregationFunction`); -- a DECLARED name this backend does not lower answers `NOT_IMPLEMENTED` / **501** - and names what it does lower. That class is empty today — every member of - `AggregationFunction` lowers here — and is pinned as a positive assertion, so - the day the spec grows a function this driver misses, the suite goes red - rather than quietly stopping to cover anything. - -Judged case-sensitively, which is what the enum is: `COUNT_DISTINCT` is not -`count_distinct`, and telling its author the backend has a capability gap would -be false. - -**Graded `patch`, deliberately.** No correct query's answer moves: all six -declared functions and the two retired ones this face still lowers -(`array_agg` / `string_agg`, an existing divergence from the SQL faces, recorded -and filed as #13075 rather than closed here) are byte-identically unchanged, -pinned by controls that compute their values in the same suite. The only inputs -whose behaviour changes are ones this driver was already answering *wrongly*, so -there is no working capability being removed — the same shape, in this same -package, that #10576's per-aggregation-`filter` refusal shipped as a patch. - -Nothing to migrate. A caller that was reaching the old `default` arm was reading -a SUM in place of the function it asked for; the refusal now names the function -and the remedy. diff --git a/.changeset/knowledge-manifest-turso-origin.md b/.changeset/knowledge-manifest-turso-origin.md deleted file mode 100644 index 926f407b71..0000000000 --- a/.changeset/knowledge-manifest-turso-origin.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Tell the operator where `@objectstack/knowledge-turso` comes from instead of -naming a package this repo does not build - -An operator who selects the `turso` knowledge adapter in **Settings → AI & -Embedder** and runs the connection test was told: "Mount -`@objectstack/knowledge-turso` to exercise live calls." That package is in no -directory of this repo (0 path hits on `main`; `knowledge-memory` and -`knowledge-ragflow` return 8 each under the identical command, so the zero is -real), and the message said nothing about where it does come from — leaving the -instruction un-followable at exactly the moment it is read. - -The prior question the card turned on — *is it published anywhere?* — is now -measured rather than assumed. Against the public npm registry on 2026-08-23, -with `@objectstack/spec` and `@objectstack/cli` as positive controls and -`@objectstack/security-enterprise` as a known-private negative control: -`@objectstack/knowledge-turso` **is published**, `latest` 6.9.0 (2026-05-27), -nine versions from 6.4.0. So the option stays — dropping it would have deleted a -working adapter. - -What it is *not* is co-installable with this platform version: 6.9.0 exact-pins -`@objectstack/spec@6.9.0` while this repo ships 17.2.0, so mounting it resolves a -second spec rather than reusing this one. The runtime message now names the -package, says this platform does not ship it, points at the ObjectStack Cloud -monorepo where it is built, and tells the operator to check for a release -matching their platform version — the framework#3366 discipline that an install -hint must carry its own edition/version boundary. - -The manifest's adapter-list comment loses the undated "mirrors the plugin -packages currently published" claim that stopped being true with nothing to -catch it, and gains the measurement with its date and method so the next reader -can re-run it. No behaviour change: `ok` and `severity` are untouched on every -branch of the test action. diff --git a/.changeset/lazy-pugs-shake.md b/.changeset/lazy-pugs-shake.md deleted file mode 100644 index 79201dbdcf..0000000000 --- a/.changeset/lazy-pugs-shake.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ApiEndpoint.target` is now **optional** in the vocabulary (#10338, maintainer ruling -2026-08-23). The key was required on every endpoint but read only for `type: 'flow'` -(executor, OpenAPI enrichment and the publish gate all address an `object_operation` -via `objectParams.object` / `.operation`) — so an `object_operation` author was forced -to write a dead string nothing consumed or cross-checked. Authoring guidance: omit -`target` on `object_operation` endpoints. The publish gate still **requires** `target` -for `type: 'flow'` — a flow endpoint that names no target flow is refused at publish, -and the runtime's structural backstop answers `501 NOT_IMPLEMENTED` for one that -reached the store another way. No migration: this is a pure widening — every previously -valid declaration (all of which carry a string `target`) still parses unchanged. diff --git a/.changeset/ledger-convergence-registration-and-one-store.md b/.changeset/ledger-convergence-registration-and-one-store.md deleted file mode 100644 index d9751dd0e9..0000000000 --- a/.changeset/ledger-convergence-registration-and-one-store.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/service-automation": minor -"@objectstack/objectql": minor -"@objectstack/core": minor ---- - -feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) - -Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling -(2026-08-26, verbatim and untranslated: 「同意」). - -## The registration follows the declaration (#12359) - -`sys_metadata_activation` is declared in `@objectstack/platform-objects`, but -the only thing that REGISTERED it was the automation service's manifest — -because flows were the ledger's first and, until packaged actions landed, only -consumer. Packaged actions are a second consumer with a different owner: their -consult and write path live on the ObjectQL engine, present in every -composition that can execute an action. - -So a deployment with actions and no automation service had no ledger table, and -the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — -correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be -reported as one) and permanently. Measured on a real boot; it is now this -change's positive test, measured on the same boot: - -``` -POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} - before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED -``` - -`PlatformObjectsPlugin` registers it now, so every composition carrying -platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, -`skill`, `position`) inherits it. **MOVE, not add** — the automation service no -longer names the object. That was not a style choice: a second code package -claiming one object throws `Object "…" is already owned by package "…"` -(ADR-0029 D3/D7), measured, so adding a registrant would have been a boot -failure rather than a duplicate. - -**Upgrade is a no-op for existing data, and that is measured rather than -asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` -step 4 routes an object by its owning package's `defaultDatasource` — so the -registrar carries the table's datasource with it: - -``` -owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' -owner com.objectstack.platform-objects (none) -> undefined (global default driver) -``` - -The ledger table already exists in live databases, so on any deployment -carrying a `cloud` datasource that difference would leave the rows in one -database and read another — every disabled artifact silently re-arming. The -ledger therefore rides its own manifest from the same plugin, carrying the -automation manifest's `scope` / `namespace` / `defaultDatasource` triple -verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, -`sys_secret`) deliberately do not get it and keep riding the project database. - -## One implementation of the §4 row contract (#12350) - -ADR-0126 §4 declares one activation ledger; it had two independent -implementations of that one row contract — `ObjectStoreFlowActivationStore` -(service-automation) and `ObjectStoreActionActivationStore` (objectql). They -agreed because the second was written from the first, and nothing structurally -held them together; §8 pre-charts `tool`, `skill` and `position`, and a third -and fourth copy is where the org-row skip and the `0`-is-false read get lost -quietly, in the direction (an artifact re-arming) nothing else measures. - -Neither consumer could import the other, so the contract now lives once in -`@objectstack/core` — the package both already depend on — as -`ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside -`InMemoryMetadataActivationStore`, `MetadataActivationRow`, -`MetadataActivationStore`, `MetadataActivationStoreEngine` and -`METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own -one-argument constructor and its own docs, and fixes the discriminator. - -**No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / -`InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and -`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / -`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` -/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same -shapes. Row semantics are byte-equivalent: install-level rows only -(`organization_id` never written), org-carrying rows skipped on read and -ignored when deciding insert-vs-update, a driver `0` read as false, -read-then-write rather than a blind upsert, and no `delete` in the engine slice -because re-enabling rewrites the row. - -Both existing pin suites stay green **unchanged**, which is what makes them the -proof the consolidation lost nothing — verified by ablation: removing the -org-row skip from the one shared implementation turns both of them red on their -own org-skip assertion, so both really reach it. diff --git a/.changeset/lifecycle-governance-tenant-scan-read-failure.md b/.changeset/lifecycle-governance-tenant-scan-read-failure.md deleted file mode 100644 index d5c4571737..0000000000 --- a/.changeset/lifecycle-governance-tenant-scan-read-failure.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a failed `sys_organization` tenant scan no longer decides a retention window (#12853) - -`LifecycleService.loadGovernance()` filled `snapshot.tenantOverrides` — the -ADR-0057 §3.2 per-tenant retention/expiry window set — behind a bare `catch {}` -whose comment named ONE benign cause ("No sys_organization (single-tenant -kernel)") while the `catch` swallowed every cause. On a connection drop, a -timeout, a permission refusal or a driver fault the map came back EMPTY, and an -empty map is not a neutral value: `reap()` and `archiveObject()` read it as -"this deployment has tuned no tenant" and fall every tenant back to the global -window. That window is wrong in both directions, and the expensive direction is -a tenant configured to retain LONGER having its rows expired early. Nothing -reported it: `GovernanceSnapshot` carries no field saying the tenant pass did not -complete, and the catch logged nothing — so the platform deleted on knowingly -incomplete evidence, without knowing the evidence was incomplete. - -The scan now discriminates by error TYPE through the shared -`isMissingTableError` predicate. An unprovisioned `sys_organization` really does -mean "no tenant overrides", so a single-tenant kernel is unchanged. Every other -cause aborts the sweep **before any policy is applied** — for a deletion action, -"do not act on incomplete evidence" is the correct failure direction, and a log -cannot bring back a reaped row. The rows a deferred sweep leaves are still there -for the next one. - -Operational posture change, deliberate and worth stating: a transient -`sys_organization` outage now costs a sweep. The abort is REPORTED, not thrown — -one `report.errors` entry per declared object plus a `warn` — because `sweep()`'s -declared contract is that it never throws and the scheduler enters it as -`void this.sweep()`, where a rejection would be unhandled. That is the same -objection #8906 recorded when it declined to rethrow from `checkGovernance` one -method below. - -Bump argued, not defaulted: `patch`. No exported signature, type, option or -report field moves — the failure surfaces through `LifecycleSweepReport.errors`, -which already exists for exactly this. The tension is honest and does not change -the answer: what a deployment observes on a failure path DOES change (a sweep -that used to complete silently now aborts and says so), but that is the -correction of a defect, not a new capability, and the sibling repairs in this -family (#8896, #8906, #9817) all shipped as `patch`. diff --git a/.changeset/lint-liveness-translation-bundle-walk.md b/.changeset/lint-liveness-translation-bundle-walk.md deleted file mode 100644 index f0414afb71..0000000000 --- a/.changeset/lint-liveness-translation-bundle-walk.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -**Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288). - -`stack.translations` is `z.array(TranslationBundleSchema)` — each item is a `TranslationBundle`, i.e. `z.record(LocaleSchema, TranslationDataSchema)`, whose top-level keys are locale codes. The lint registered `{ type: 'translation', key: 'translations' }` in `TYPE_COLLECTIONS` and then walked those items flat, the way every other collection there is walked: `checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. A bundle has no `flows` key at any depth reachable that way — the groups live one level down, under each locale — so every warned lookup missed and the whole `translation` ledger was silent for file-authored bundles, the only way apps author translations today. - -That is the failure mode the comment above `TYPE_COLLECTIONS` names ("a newly governed type needs its collection registered or its ledger warns nobody"), reached from the other side: the collection *was* registered, and the shape underneath it was the mismatch. Registering a collection is only half the contract — the walk has to match the collection's shape — so the row is now a tombstone comment saying exactly that, and `translation` joins `object`/`field` as a bespoke walk: for each bundle, each locale entry's `TranslationData` is checked, with the finding subject naming the bundle index and the locale (`translation bundle #0 · locale 'zh-CN'`). - -Measured on a real app before the fix, as a guarded ablation: injecting a `flows:` section into a locale bundle and re-running `objectstack lint --json` produced **zero** delta — 91 issues before and after, 0 liveness findings naming `flows`. The author who reached for a `planned` translation group got silence, which is strictly worse than the ledger being absent, because the ledger's stated contract is that `authorWarn` is what tells them. - -Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings. - -The regression test is pinned on the **bundle** shape, and a `TranslationItem`-shaped anti-fixture is pinned alongside it. That shape — `locale` plus the groups at the top level — is the runtime metadata door, and it *warned on the broken walk*, so a fixture written that way would have been green from the day the bug shipped and pinned nothing. Runtime-authored `translation` items are reached by this lint through no door at all: no stack collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does not run at the runtime publish gate either. The two doors share the group vocabulary, not the container; only the file-authored one is lintable, and now it is linted. diff --git a/.changeset/listview-map-liveness-live.md b/.changeset/listview-map-liveness-live.md deleted file mode 100644 index bbadea09ea..0000000000 --- a/.changeset/listview-map-liveness-live.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Liveness-ledger verdict:** `view.list.map` (`ListViewSchema`'s view-level `map` block) moves `planned` → `live` (#11442). - -objectui#5908 landed the missing half of the forward: `packages/plugin-list/src/ListView.tsx`'s `resolveListMapConfig` now merges the view-level `map` block over the legacy `options.map` bag before `case 'map'` builds the `object-map` schema, and the same merged config also feeds the visualization-switcher's capability gate — so a view that binds its coordinates only in the spec's `map` block both renders on the map surface and is no longer filtered out of `allowedVisualizations`. Nothing about the schema shape or the authoring-time validation changed; only the runtime consequence of authoring the key does. - -Pinned by objectui `packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx` (the forward, against a spy) and `packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx` (the end-to-end read through a real `ObjectMap` — markers, titles, camera). Re-measured against objectui `origin/main@08ca73f8` (squash commit `e2e8e68` for #5908 confirmed an ancestor). diff --git a/.changeset/liveness-action-dispatch-anchors.md b/.changeset/liveness-action-dispatch-anchors.md deleted file mode 100644 index 6183968056..0000000000 --- a/.changeset/liveness-action-dispatch-anchors.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: repoint the `action` `type` / `body` / `method` anchors at their real consumers - -All three cited `packages/runtime/src/http-dispatcher.ts`, and none of them is read -there. The actions domain was extracted out of that file — it now only delegates -(`handleActions` → `handleActionsRequest` at :1969-1970) — and the reads live in -`action-execution.ts`, `domains/actions.ts`, `sandbox/body-runner.ts` and, for the -client-dispatched `method`, in the renderer repo. - -This is the residue of the same extraction that rotted `action.target` and -`action.requiredPermissions`, but it survived the sweep that repaired those, and the -reason is the interesting part. The key-mention check added with that sweep asks -whether the cited file names the key at all — which caught `target` and -`requiredPermissions` because `http-dispatcher.ts` contains **0** occurrences of -either. It cannot catch these three, because they are common English and HTTP words -that the file is full of for unrelated reasons: - -- `type` — 9 occurrences, four of them the TypeScript `import type` keyword, the rest - other domains' data (`error.type`, `details.type`, a field-type→JSON-Schema mapper). -- `body` — 68 occurrences: the inbound HTTP request body threaded through every - domain delegate, plus the file's own "body extracted to ./domains/…" comments, - where `body` means a *function* body. -- `method` — 41 occurrences: the inbound HTTP verb (15 `method: string` parameter - declarations, route matching such as `method === 'GET'`) and the ordinary - object-oriented sense in prose. - -So the word-bounded check anchors on the coincidence and passes. That is the designed, -honest limit of the signal rather than a defect in it — the census that shipped it said -so — and it means this class is invisible to tooling and only a hand call-graph read -can settle it. Each repointed entry now records which spelling misled the gate, so the -next reader does not have to re-derive it. - -`method` additionally CHANGES REALM, joining its siblings `bodyShape` and `bodyExtra`: -`type: 'api'` actions are client-dispatched by design, so the server never read the -verb. Its one in-repo appearance is a diagnostic that interpolates the verb into the -refusal explaining the server does *not* dispatch it — evidence of non-consumption, -and deliberately not cited as a consumer. - -No verdict was re-graded: all three were `live` and remain `live`, with the consumer -proven rather than asserted. Citation repair only. diff --git a/.changeset/liveness-citation-key-mention.md b/.changeset/liveness-citation-key-mention.md deleted file mode 100644 index f1ac1d49b4..0000000000 --- a/.changeset/liveness-citation-key-mention.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness gate: a citation must name the property it is evidence for - -Two checks already bounded a `live` entry's citation, and both bounded it from the -outside — the cited file must exist (#5623), and a cited line must be inside it -(#11210). Between them sat a gap neither could see: a consumer that moves *within* -the file it is cited to, or a citation written with no line at all, leaves the file -present and every named line in range. The pointer is wrong and the gate is green. - -Measured over the whole ledger before anything was switched on: 403 (entry, cited -local file) pairs, **11** where the cited file never mentions the property's own key, -and **7 of those 11 were real rot** — repaired here: - -- `permission.objects.allowExport` — `annotateEffectiveApiOperations` moved to - `current-user-endpoints.ts`; the same repos-internal movement that had already - rotted `permission.systemPermissions` and `permission.tabPermissions`. -- `object.tenancy.organizationField` — the resolver was promoted into - `@objectstack/metadata-core`; the cited `audit-writers.ts` says so itself, in the - re-export comment left behind. -- `action.target` / `action.requiredPermissions` — the actions domain was extracted - out of `http-dispatcher.ts`, which retains 0 occurrences of either key. -- `action.bodyShape` / `action.bodyExtra` — client-dispatched keys whose only - consumer has always been the renderer; the in-repo citation could not have been - right at any point. Now attributed to `objectui` with the commit pinned. -- `field.requiredWhen` — cited its *sibling* `record-validator.ts`, which enforces the - static `required` contract; the CEL predicate is evaluated one file over in - `rule-validator.ts`. Both files exist, so nothing could see it. - -The remaining 4 are the `camelCase` → `snake_case` convention this platform mandates -(Prime Directive #3): a property persisted as a column is read as `body_html`, -`managed_by`, never as the authoring key. Three are handled **structurally** — the -matcher folds the key across the naming convention rather than exempting them — and -the match is word-bounded so a prefix cannot satisfy the key (`required` is not -`requiredWhen`, which is precisely how that rot stayed hidden). The one residual is a -compound *child*-key remap (`fromOverride.address` → `from_address`) that no fold of -the parent key reaches, and it is a single explicit row in the shrink-only -`scripts/liveness/key-mention.baseline.json`, which fails in **both** directions: a -row whose pair later anchors must be deleted. - -So the check ships red-capable at zero unexplained hits, which is the whole reason the -census came first — `evidence.mts`'s header records what the alternative costs, when -48 of 227 entries were flagged, every one was a false positive, and the single genuine -rot inside that list sat unread. - -The check asks `evidence` only, never `producer`: a producer cites *who supplies a -second input* (#4837), which is by definition a call site and need not name the key at -all. diff --git a/.changeset/liveness-citation-line-bound.md b/.changeset/liveness-citation-line-bound.md deleted file mode 100644 index 37b25fddd0..0000000000 --- a/.changeset/liveness-citation-line-bound.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness gate: bound an evidence citation's LINE, not just its file - -`check:liveness` resolved a `path/to/file.ts:NNN` pointer with `existsSync` on the -path alone — the parser did not even retain the `:NNN` — so a consumer that moved -out of a file which still exists kept a passing pointer, was counted under the word -"resolved", and left its ledger entry reading as freshly verified. A citation that is -dead but precise-looking is worse than a missing one: it survives review, and the next -agent re-verifying the entry follows it, finds nothing, and rebuilds the call graph -from scratch. - -Citations are now bounded by the cited file's length, for `evidence` and `producer` -alike (they already share one resolver). A range `:12-34` is bounded by its END. Every -citation in a `+`-joined multi-consumer entry is bounded, not just the first. -Cross-repo attributions (`objectui: …`, `cloud: …`) are still counted and never -resolved. The run prints how many citations it checked beside how many are in range, -so a parser that degraded to extracting nothing cannot read as a pass. - -Two shipped instances, both repaired here and both real: -`permission.tabPermissions` cited `hono-plugin.ts:1200` in a 717-line file that no -longer mentions the property (all three of its pointers were dead — one past EOF, two -within bounds), and `mapping.fieldMapping` cited a range ending three lines past the -end of `import-mapping.ts`. diff --git a/.changeset/liveness-lint-null-collection-item-guard.md b/.changeset/liveness-lint-null-collection-item-guard.md deleted file mode 100644 index 4998eb6888..0000000000 --- a/.changeset/liveness-lint-null-collection-item-guard.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`lintLivenessProperties` now honours its own docblock contract ("Advisory only -— returns findings, never throws") when a collection item is `null` or -otherwise not an object. The object walk, the field walk nested under it, and -the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow, -action, agent, tool, …) each read `item.name`/`item.object` straight off every -element with no record guard, throwing `TypeError: Cannot read properties of -null (reading 'name')` on a malformed item instead of skipping it — reachable -via the exported `stack: AnyRec` signature on an unparsed or hand-built stack. -The translation bundle walk already guarded its two levels (#11383); this -closes the same hole on the three walks that did not (#11385). diff --git a/.changeset/liveness-symbol-anchors-action-object.md b/.changeset/liveness-symbol-anchors-action-object.md deleted file mode 100644 index 3f9bbc757f..0000000000 --- a/.changeset/liveness-symbol-anchors-action-object.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -chore(spec): re-anchor the `action` and `object` liveness ledgers to consuming symbols (#13003) - -Adoption batch 1 of the symbol-anchor citation grammar landed by #12516. The -`liveness/` ledgers ship inside this package's npm tarball (they are named in -`files`), so this is a published-data change even though no runtime behaviour -moves and no schema key changes. - -Twenty `path:NNN` evidence citations across `liveness/action.json` and -`liveness/object.json` are now written `path#symbol`, each re-closed by reading -the code on the current tree rather than by shifting a line number. A symbol -moves with its consumer, so the pointer survives the in-file drift that rots a -line, and goes red when the consumer is renamed or deleted — a direction a -stale line can never produce. - -What the re-closure found, which is the reason the migration is not mechanical: -fourteen of the fifteen `object.json` citations were pointing at the wrong place -already, every one of them IN RANGE and so invisible to all three existing -checks. They had drifted onto a docblock about aggregate-function lowering, a -job-scheduling block, a neighbouring `const`, and — for `object.enable.clone` — -a sort-node normalizer roughly 6,700 lines from its actual reader. Three had -additionally moved package: the `ownership`, `managedBy` and `tenancy.enabled` -readers now live in `@objectstack/spec` itself, and one of those citations -carried a parenthetical (`applySystemFields reads schema.ownership`) that the -re-read falsified outright — that function's nine `ownership` occurrences are -all comments about a decision it delegates. diff --git a/.changeset/liveness-symbol-anchors-batch-2.md b/.changeset/liveness-symbol-anchors-batch-2.md deleted file mode 100644 index 835c0633b8..0000000000 --- a/.changeset/liveness-symbol-anchors-batch-2.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -chore(spec): re-anchor the `manifest`, `datasource`, `permission`, `dataset` and `webhook` liveness ledgers to consuming symbols (#13003) - -Adoption batch 2 of the symbol-anchor citation grammar landed by #12516, following -batch 1's `action` / `object` pass. The `liveness/` ledgers ship inside this -package's npm tarball (they are named in `files`), so this is a published-data -change even though no runtime behaviour moves and no schema key changes. - -One hundred and five `path:NNN` evidence citations across -`liveness/manifest.json` (34), `datasource.json` (28), `permission.json` (25) and -`dataset.json` (18) are now written `path#symbol`, each re-closed by reading the -code on the current tree rather than by shifting a line number. A symbol moves -with its consumer, so the pointer survives the in-file drift that rots a line, -and goes red when the consumer is renamed or deleted — a direction a stale line -can never produce. Every entry touched is stamped `verifiedAt: 2026-08-28`, which -dates `datasource.json`, `dataset.json` and `webhook.json` for the first time. - -What the re-closure found, which is again the reason the migration is not -mechanical: 88 of the 105 citations were pointing at the wrong place already, -every one of them IN RANGE and so invisible to all three existing checks. They -had drifted onto docblocks, blank lines, a `variant: 'secondary'` UI action, a -`return m.aggregate` belonging to a different key, and — for `datasource.ssl` — -a contiguous run of five wrong pointers into a docblock about an unrelated -environment variable. Two prose claims were falsified outright and are withdrawn: -`external.validation`'s note credited `checkOnBoot` with gating the boot sweep -(nothing reads that key anywhere; the sweep is unconditional), and -`objects.viewAllRecords` cited a reader named `hasViewAllData` that no longer -exists in the tree. - -`webhook.json` is the batch's separate case. All eleven of its `live` entries -cited bare FILENAMES with line numbers (`auto-enqueuer.ts:266`), which the -evidence scanner cannot parse as citations at all — so that ledger contributed -zero resolvable paths and was never actually asked about by any check. Rewriting -them as repo-rooted anchors made them askable, and the key-mention check -immediately found a real answer: the dispatcher reads the remapped `active` -column and never the authored `isActive`, so that call site now lives in the -entry's note rather than in its evidence. - -`tool.json` is deliberately unchanged: every one of its citations points into -`packages/services/service-ai`, the closed cloud runtime, which is absent from -this checkout — there is no code here to re-close it against. diff --git a/.changeset/liveness-symbol-anchors-batch-3.md b/.changeset/liveness-symbol-anchors-batch-3.md deleted file mode 100644 index 16d1c2eed2..0000000000 --- a/.changeset/liveness-symbol-anchors-batch-3.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -chore(spec): re-anchor the `email_template` / `api` / `doc` / `book` / `query` / `job` liveness ledgers to consuming symbols (#13003) - -Adoption batch 3 of the symbol-anchor citation grammar landed by #12516, -file-disjoint from batches 1 and 2. The `liveness/` ledgers ship inside this -package's npm tarball (they are named in `files`), so this is a published-data -change even though no runtime behaviour moves and no schema key changes. - -Ninety-five `path:NNN` evidence citations across `liveness/email_template.json`, -`api.json`, `doc.json`, `book.json`, `query.json` and `job.json` are now written -`path#symbol`, together with every path-only pointer in the same entries, each -re-closed by reading the code on the current tree rather than by shifting a line -number. A symbol moves with its consumer, so the pointer survives the in-file -drift that rots a line, and goes red when the consumer is renamed or deleted — a -direction a stale line can never produce. - -What the re-closure found. Seventy-nine of the ninety-five citations were already -pointing at the wrong place, every one of them IN RANGE and so invisible to the -existence check, the line bound and the key-mention check alike. Four whole files -were 100% rotted — `doc.json` 15 of 15, `book.json` 13 of 13, `query.json` 12 of -12, `job.json` 11 of 11 — and the sixteen accurate citations that survive are -concentrated in two places: twelve of the thirteen row-mapping pointers in -`email_template.json`, and four pointers into short, stable policy helpers in -`api.json`. In `doc.json`, its `book.zod.ts` pointers had come to rest inside -the `ResolverDoc` / `ResolvedEntry` INTERFACES — type declarations of the very -fields whose consumers they claimed to cite, which is what a `dead` key has too — -while the whole doc-serving block of `rest-server.ts` moved roughly 1,600 lines -out from under the rest. `email_template.json`'s nine `email-service.ts` pointers -rotted together when #9225 split the resolver out of `sendTemplate` into -`resolveAndRenderTemplate`, moving the reads from the 400s to the 1,170-1,310 -band; one of them landed 1,150 lines away on a docblock about the `sys_email` -outbox id contract. Two security-shaped keys had their only pointer land on a -DIFFERENT key's enforcement: `api.authRequired` cited the rate limiter's 429 -body, and `email_template.variables` cited the exact line its sibling `isSystem` -cites for itself. - -Two pointers were falsified in PROSE as well as position. `api.cacheTtl` cited a -line that was still accurate and named `cacheControlHeader`, which is not a -symbol anywhere in `packages/**` (the function is `computeCacheControl`); -`query.where` named `applyFilters`, which is real and sits roughly 11,250 lines -from the position cited beside it. - -Two entries were not checkable at all rather than merely stale. -`query.expand`'s pointer was written `engine.ts:2519+`, and the trailing `+` -stops the token matching the scanner's path pattern — so it degraded silently to -prose and was never resolved, bounded or key-checked, while reading like the most -precise citation in the file. `query.aggregations.filter`, the youngest entry in -that ledger, wrote five of its eight consumers as bare package+filename prose -(`driver-sql sql-driver.ts`), which the same pattern cannot parse. Both are now -repo-rooted anchors — the `webhook.json` class from batch 2, reproduced here once -by a single character and once by a missing prefix. - -`job.json` is the file that argues the case most directly: its 2026-08-02 note -records that the seeded lines had already drifted ~25 lines and were restamped -with fresh numbers. Twenty-six days later every one of those fresh numbers had -drifted again, onto `} else {`, a bare `try {`, a bare `}` and a line about -registering actions. Restamping a line is the same claim with a newer date. - -Nothing is re-classified here. Entries whose evidence lives only in `objectui` at -a pinned commit this checkout cannot reproduce — `book.description` / `.slug` / -`.icon` / `.order` and `job.label` / `.description` — are left byte-for-byte -untouched and are NOT re-stamped, on the `tool.json` precedent: dating a call -graph nobody re-closed is the false confidence this ledger exists to prevent. diff --git a/.changeset/liveness-symbol-anchors-batch-4.md b/.changeset/liveness-symbol-anchors-batch-4.md deleted file mode 100644 index 8c66921972..0000000000 --- a/.changeset/liveness-symbol-anchors-batch-4.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -chore(spec): re-anchor the closing twelve liveness ledgers to consuming symbols (#13003) - -Adoption batch 4 — the closing batch — of the symbol-anchor citation grammar -landed by #12516. The `liveness/` ledgers ship inside this package's npm tarball -(they are named in `files`), so this is a published-data change even though no -runtime behaviour moves and no schema key changes. - -Eighty of the eighty-two remaining `path:NNN` citations — across -`liveness/app.json`, `validation.json`, `translation.json`, `field.json`, -`hook.json`, `mapping.json`, `capability.json`, `seed.json`, `view.json`, -`dashboard.json`, `flow.json` and `qa.json` — are now written `path#symbol`, -each re-closed by reading the code on the current tree rather than by shifting a -line number. Every path-only pointer in the same entries is anchored with them. -The gate's own line-citation counter goes 82 to 2 and its symbol-anchor counter -383 to 499. - -Sixty-seven of the eighty were already wrong, every one IN RANGE and therefore -invisible to the existence check, the line bound and the key-mention check alike. -Six files were 100% rotted (validation 10/10, seed 6/6, view 5/5, dashboard 4/4, -flow 4/4, qa 4/4). The dominant shape is BLOCK drift: nine of `validation.json`'s -ten pointers had come to rest inside one 70-line ADR-0124 docblock roughly 1,200 -lines above `evaluateValidationRules`, and `app.json`'s `name` / `label` / -`description` had settled on three consecutive lines of one unrelated action -resolver. `flow.status` is the sharpest single case — its pointer landed on -`flowLedgerDisabled`, the map ADJACENT to the one this key writes, after #10243 -split the two apart. - -Two silent classes are closed with them. Seventeen positions across four files -were written as bare `:NNN` suffixes with no path in front of them, which the -scanner never matched as citations at all, so nothing ever resolved, bounded or -key-checked them; `translation.objects` alone carried five. Three more entries -(`hook.retryPolicy` / `timeout` / `onError`) cited a path with no line, which a -line bound cannot falsify by construction. All twenty are repo-rooted anchors now. - -Three citations were falsified in PROSE as well as position and are corrected -with their lines: `view.object` credited `getViewsByObject()` to -`packages/objectql/src/engine.ts` (it is in `packages/metadata`), -`field.requiredWhen` named `fieldRequiresParentRoot` (no such symbol; it is -`hasParentScopedRequiredWhen`), and `translation.metadataForms` named -`translateMetaTypes` (it is `translateMetaTypesResponse`). - -Nothing is re-classified. Two entries are deliberately left byte-for-byte -untouched and un-restamped because their `live` verdict — not their citation — -is what is falsified: `action.execute` (reported as #13036) and -`field.conditionalRequired` (reported as #13043), both `retiredKey` tombstones -since protocol 17 whose cited `.transform` lowering no longer exists. Those are -ADR-0049 re-classifications with their own card shape. diff --git a/.changeset/llms-txt-freshness-gate.md b/.changeset/llms-txt-freshness-gate.md deleted file mode 100644 index bbfccdcb46..0000000000 --- a/.changeset/llms-txt-freshness-gate.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Gate the shipped `llms.txt` against the real package, and repair the claims that had rotted. - -`packages/spec/llms.txt` ships in the npm tarball as context for AI consumers and is -hand-kept with no generator, so nothing ever re-derived what it asserts. It had drifted -badly: eleven advertised symbols existed in no entry point (`IUIService` — removed in -v11 — plus `ThemeSchema`, `IdentitySchema`, `PolicySchema`, `ContractSchema`, -`EndpointSchema`, `RAGPipelineSchema`, `MCPSchema`, `FilterSchema`, `AnalyticsSchema`, -`FormSchema`), two advertised packages did not exist (`@objectstack/nextjs`, -`@objectstack/nestjs`), the schema-inventory heading disagreed with the sum of its own -table (171 vs 170) and with the tree (207), and the package heading claimed 19 against a -real 68. An agent reading the file wrote imports that do not resolve. - -New gate `check:llms-txt` re-derives every checkable claim on every PR: advertised -symbols against the checked-in `api-surface/` shards, `@objectstack/spec/x` subpaths -against the manifest `exports`, the per-domain schema counts against -`src//**/*.zod.ts`, and the package table against the workspace. Symbol claims -are resolved at the strictness their position earns — namespace bullets and fenced -imports name an entry point and must resolve from it, while the architecture overview -resolves against the union. Prose, code-fence bodies and `N+` lower-bound figures are -out of population and the script header says why. - -There is deliberately no `gen:llms-txt`: the numbers are not the claim, the prose beside -them is, and restamping a count without re-reading its row would turn a loud staleness -into a silent lie. diff --git a/.changeset/localization-failure-memo-backend-leg-only.md b/.changeset/localization-failure-memo-backend-leg-only.md deleted file mode 100644 index ef9fb5b170..0000000000 --- a/.changeset/localization-failure-memo-backend-leg-only.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877) - -`resolveLocalizationContext` memoizes an outcome for 30s whenever the read -"failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run, -and the driver does not re-log it, on every request). The write condition was -wider than the cache's own docblock: six legs set the flag and only **one** of -them is the backend fault the docblock describes (the direct `ql.find` throw). -The other five are the **settings service refusing** — a thrown `getMany`, each -of the three older per-key `get`s, and the whole-block "service unavailable" -handler. - -Those five legs are reachable inside the settings engine's **bind window** -(`SettingsService.getMany` refuses all-or-nothing for a `localization` -namespace whose manifest is not yet registered), so: - -- A caller that deliberately re-reads **after** the bind — the #11580 stdio - repair re-resolves at `kernel:bootstrapped` for exactly this reason — was - answered from the memo taken **inside** the window for up to 30s. The - correction silently did not happen, with nothing in the output saying so. -- A settings refusal standing alongside a perfectly **successful** direct read - memoized that successful value — the staleness the docblock forbids outright - and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch. - -The memo is now written only for the direct-read fault. **#10221's protection -is unchanged for the legs it was built for**: its environment (table not -migrated yet) still memoizes, because the direct read throws there whether or -not a settings refusal stands in front of it — pinned in both directions. And -nothing is lost on the narrowed legs: those refusals throw out of an in-memory -registry check *before* any query and *before* any log line, so memoizing them -suppressed neither. - -No signature, export or accepted-input change — the flag is internal to the -module. diff --git a/.changeset/localization-success-read-cache.md b/.changeset/localization-success-read-cache.md deleted file mode 100644 index d92750aa7f..0000000000 --- a/.changeset/localization-success-read-cache.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): cache successful `sys_setting` localization reads, invalidated synchronously on write (#11966) - -Leg C (ship-first) of the accepted #11633 cross-request caching design -(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0). -`resolveLocalizationContext` re-read `sys_setting` on **every** authenticated -request to answer the same three keys — `timezone` / `locale` / `currency` — -for a workspace whose values change roughly never. That read is now cached. - -**Grade: `minor`, not `patch`.** It adds a deployment variable -(`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code -path. Not `major`: the observable contract callers actually depend on — a -settings write is visible to the very next read — is preserved, and pinned. - -Caching this read was tried once before and reverted. #10221's first version -memoized every outcome for 30s and CI went red on -`analytics-timezone.dogfood.test.ts`, which writes a new org timezone and -expects the very next analytics query to bucket under it; the cache was narrowed -to memoize **failures** only. That verdict was on **TTL-only** caching and it -still stands unamended. What changed is that the process now has invalidation -seams it did not have then: - -- **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)` - dispatches synchronously and in-process from the write path, after the row is - persisted. (⚠️ #11633 calls this a "settings change bus"; no such module - exists — `subscribe()` is the seam. No change was needed in - `@objectstack/service-settings`: the seam was already public and already does - exactly this.) -- **Backstop — the engine write epoch** from #11968's substrate. Needed because - this resolver's own fallback reads `sys_setting` *directly*, so a seeder or - any other direct engine write emits no settings event at all. It is read - structurally rather than imported, because `@objectstack/objectql` depends on - `@objectstack/core` and the substrate declared `WriteEpochLike` separately for - exactly this consumer. A peer node's hint arrives as a local bump, so an - attached `authz.invalidated` bridge narrows cross-node convergence for free. -- **TTL** — the residual bound, for what neither seam can see. Default 30s, - `0` disables the cache on a real path rather than a degenerate one. - -Two rules carry the change and are pinned rather than merely documented: - -1. **A success is cached only when the engine exposes the write epoch.** A `ql` - with no seam is a `ql` whose writes the cache cannot observe, so rather than - degrade to the TTL-only shape that was already reverted here once, the cache - declines. A partial `{ current }` shape is not a seam either — a counter - nothing can bump would read as a live invalidation source and pin the answer - for a whole TTL. -2. **Invalidation retires success entries only.** #10221's failure memo exists - for an environment where `sys_setting` is missing; retiring it on a write - would restart precisely the per-request driver log spam that memo removed, - and no write can create a missing table. It stays TTL-bound and behaves - exactly as #10221/#11877 shipped it. - -`analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this -leg's acceptance test, and an ablation that reduces the cache to its TTL turns -it red on the same assertion the original revert was recorded against. diff --git a/.changeset/lock-packaged-permission-set.md b/.changeset/lock-packaged-permission-set.md deleted file mode 100644 index dbc1d9d4ff..0000000000 --- a/.changeset/lock-packaged-permission-set.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -Lock package-declared permission sets at the save door; clone to customize (#11513) - -Maintainer ruling of 2026-08-24, recorded verbatim and untranslated: -「同意 第一步(创业阶段,Salesforce 式)」 — step 1 of the mainstream-platform -comparison: lock the base, clone to customize. - -A Studio/API save that targets a **package-declared** permission set is now -**refused at the server**, with a message that names the sanctioned path — clone -it and edit the clone. Previously the data door translated the write into a -metadata write and left the refusal entirely to the metadata protocol's ADR-0005 -tier gate. That gate is exactly what the documented -`OS_METADATA_WRITABLE=permission` operator hatch switches off, so on a -deployment running with the hatch there was no refusal at all: the save minted a -`sys_metadata` overlay of a packaged set, and boot reconciliation re-projected -that overlay onto the record on every boot, unconditionally, forever — the set -froze at the fork and every future package upgrade of it was ignored, silently. - -**Clone-to-customize** is the sanctioned path and is unchanged: the clone is an -ordinary org-owned set (`managed_by: 'admin'`, no `package_id`, so no upgrade -linkage), and upgrades keep flowing to the package-declared base untouched. - -**Existing forks** get a **detection reading** at boot — count *and names*, -warned loudly, saying outright that nothing was reaped. It reads `sys_metadata` -directly rather than the `customized` column, which is forced `false` on the -exact confounded shape the field report measured (a genuinely package-declared -set whose row's `managed_by` predates provenance tracking). Nothing is reaped, -merged or migrated: disposition of an existing fork is a follow-up reading for -the maintainer, and the per-set remedy remains the explicit, audited -"Discard Overlay" action a human invokes. - -Behaviour deliberately NOT narrowed: - -- an **ordinary org-owned** set is still fully editable (pinned as a control — - a lock that refuses everything would satisfy the refusal pin perfectly); -- the **activate / deactivate** actions still write their column: a bare - `{ active }` patch is row state, not a customization of the definition; -- a `managed_by: 'package'` row with **no artifact behind it** — published - through the metadata door (ADR-0070) and materialized by the ADR-0086 P2 - path — keeps editing in place. That is ADR-0094 D5-R's surviving - `allowRuntimeCreate` neighbour, and `managed_by` is measurably not the - artifact-provenance fact. Provenance is read from the engine SchemaRegistry, - the one source this plugin already calls "package-declared". - -Provenance is **fail-closed**: a read that cannot answer refuses the save rather -than accepting it, and the read is not a name-keyed page over -`sys_permission_set`, so it cannot be truncated into a false "not packaged". diff --git a/.changeset/lucky-donkeys-vanish.md b/.changeset/lucky-donkeys-vanish.md deleted file mode 100644 index bc91a5e8c5..0000000000 --- a/.changeset/lucky-donkeys-vanish.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/observability": minor -"@objectstack/cli": minor ---- - -Surface a licensed `max_nodes` oversell to operators as telemetry - -`os serve` already warned loudly at boot when `OS_CLUSTER_REPLICAS` declared more -nodes than the licence gate admits, but that warning existed only in one -process's startup output: an operator who scaled past their cap three weeks ago -had no way to ask the question today, and no way to alert on it. The same -advisory verdict is now also published through the deployment's configured -metrics backend, so it reaches the place operators already look. - -Three names join `SEMCONV` in `@objectstack/observability`, emitted once per boot -by `os serve` when a remote cluster driver is configured, each labelled with the -gate's own verdict vocabulary (`admitted` / `capped` / `refused`): - -- `cluster_declared_nodes` (gauge) — the replica count the operator **declared**; -- `cluster_admitted_nodes` (gauge) — how many of them the licence **admits**; -- `cluster_node_cap_verdicts_total` (counter) — one increment per process boot - that consulted the gate, so an alert stays writable after a one-shot gauge has - aged out of a push-based backend. - -**Visibility only — the cap remains advisory and nothing is refused.** The gate is -consulted once per process at boot, every replica computes the same verdict, and -none can know whether it is one of the admitted ones, so all of them still join. -The names say so on purpose: this process has no cluster membership view at all, -so a series called `cluster_nodes` or `cluster_active_nodes` would be a false -statement dressed as telemetry. Nothing here counts peers, and no accept/reject -behaviour changed. - -Absence is meaningful rather than an instrumentation gap: a single-node -deployment never consults the gate and emits nothing, and an emission also needs -a metrics backend configured via `OS_OBS_EXPORTER`. diff --git a/.changeset/lucky-jokes-shave.md b/.changeset/lucky-jokes-shave.md deleted file mode 100644 index c6bfb67b4f..0000000000 --- a/.changeset/lucky-jokes-shave.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/runtime": minor -"@objectstack/spec": minor ---- - -Packaged actions can be switched off, on the same activation ledger as flows - -A packaged action can now be disabled for an installation, generalizing the -packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on -the maintainer's amendment ruling 3). The flip writes an install-level row to -the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — -no new table, no new column, no schema change of any kind. Absence of a row -means the packaged default, active, so a deployment that never flips anything -behaves exactly as before, and an empty ledger changes nothing anywhere. - -The consult point is action DISPATCH, and it is present on every door that -dispatches a declared action: the REST `POST /actions/:object/:action` route and -the MCP `run_action` bridge. Both call one shared guard, and a disabled action -is refused `409 ACTION_DISABLED` before anything runs — before the handler body -(which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action -reaches the automation engine, before the param contract is enforced and before -the subject record is read. The refusal names the ledger and the remedies. The -code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and -answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, -which would tell an operator to go looking for a flow that does not exist. - -The consult reads a projection the ObjectQL engine holds and hydrates at boot, -so a disabled action stays disabled across a restart and across the handler -re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — -the ledger records the customer's choice, and nothing re-arms it silently). - -The write door is `POST /actions/_activation/:object/:action` with a -`{ enabled?: boolean }` body. Its first segment is reserved rather than deep in -the path because a machine name can never begin with `_`, so it cannot collide -with an object, an action or a record id. It carries the same two authority -tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture -rule — in the `group` and `isolated` postures the install-wide switch requires -the platform operator, while `single`, where install-level and org-level are the -same scope, is unchanged. That gate is now one implementation shared with -`POST /automation/:name/toggle`; the flow refusal text is unchanged. - -Two refusals are worth knowing about. The ledger addresses an action by its -machine name, so a name declared on more than one object is refused with -`409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off -silently. And a flip that cannot be made durable — no ledger table reachable — -is answered as a failure instead of a 200, because a switch reported as durable -that reverts on the next restart is the failure this whole family exists to -remove. - -Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so -disable is the only primitive here and authoring a new sibling action stays -exactly as it is today. diff --git a/.changeset/manager-of-org-screen.md b/.changeset/manager-of-org-screen.md deleted file mode 100644 index 5de7ee32bc..0000000000 --- a/.changeset/manager-of-org-screen.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -fix(sharing): honour the declared `organizationId` in `managerOf` (#10231) - -`ITeamGraphService.managerOf(userId, organizationId?)` declares an organization -parameter. `TeamGraphService.managerOf` spelled it `_organizationId` and -discarded it, and the `BusinessUnitGraphService` standalone fallback read -`sys_user` the same unscreened way — a declared-but-unenforced parameter on a -security seam, while `expandRoleUsers` on the same class applied -`organization_id` to its own read. - -Both now apply the screen #10153 landed for the identical column -(`sys_user.manager_id`) on the approvals side: a manager who is **provably** -outside the caller's organization — membership rows exist for him, none of -them in that organization — is dropped. The read is `sys_member`, because -`sys_user` is the global better-auth identity table and carries no -`organization_id` at all; filtering the `sys_user` read on a column that does -not exist would match nothing and silently return `null` for every lookup. - -The screen is fail-open on an ABSENT tenancy fact (no membership rows, or the -membership read failed) and issues no query at all when no organization is in -play, so callers that pass nothing — which is how the parameter is used today — -are byte-identical to before. The manager cache key is now organization- -qualified; a user-keyed cache would have served one screened `null` to every -unscoped reader behind it. diff --git a/.changeset/manifest-contributes-dead-members-retired.md b/.changeset/manifest-contributes-dead-members-retired.md deleted file mode 100644 index cc284cc7a5..0000000000 --- a/.changeset/manifest-contributes-dead-members-retired.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the nine dead members of the plugin-manifest `contributes` block — `events` / `menus` / `themes` / `translations` / `actions` / `drivers` / `fieldTypes` / `functions` / `commands` (#10724, ADR-0049 enforce-or-remove) - - - -**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). - -The census behind it (#10627, controlled and re-verified at claim time across -objectstack, objectui and cloud) measured that the ENTIRE monorepo contains -exactly one non-test read of `manifest.contributes`, and it reads `kinds` -(`packages/objectql/src/engine.ts` → `registry.registerKind`). The other nine -members parsed, entered the manifest, and changed nothing — while published -material kept teaching them: `commands` documented Commander.js runtime -resolution the CLI dropped for oclif auto-discovery, `fieldTypes` advertised a -registration seam that has never existed, and `events` was decorative even for -its only in-repo author, which already subscribes imperatively. - -**What is refused:** authoring any of the nine keys. Each is a `retiredKey()` -tombstone (the `manifest.loading` precedent — neither `ManifestSchema` nor the -`contributes` object is `.strict()`, so a plain deletion would have silently -stripped the keys), so authoring one is a `tsc` error and a parse error -carrying the per-key prescription. - -**FROM → TO, per member** (each tombstone carries its own one-line fix): - -- `contributes.events` → subscribe in plugin code (`ctx.hook('kernel:ready', …)` - from `init`/`start`); delete the key. -- `contributes.menus` → app `navigation` / `manifest.navigationContributions` - (ADR-0029 D7); delete the key. -- `contributes.themes` → the stack-level `themes` metadata collection (an - unrelated `ThemeSchema` surface); delete the key. -- `contributes.translations` → the `translation` metadata type: - `defineTranslationBundle` in `defineStack({ translations })`; delete the key. -- `contributes.actions` → the stack `actions` collection or - `engine.registerAction`; delete the key. -- `contributes.drivers` → register a kernel service named `driver.*`; delete - the key. -- `contributes.fieldTypes` → nothing (no registration seam exists; the - vocabulary is the spec `FieldType` enum); delete the key. -- `contributes.functions` → `defineStack({ functions })`; delete the key. -- `contributes.commands` → oclif native plugin auto-discovery (an `oclif` - section in the plugin's own `package.json`; see `cli-extension.zod.ts`); - delete the key. - -**What stays:** `contributes.kinds` (the block's one live member) and -`contributes.routes` (an open enforce-or-remove fork, #10726 — deliberately -untouched here). Runtime behaviour is unchanged: nothing ever read the nine -members, so removing them removes no behaviour; a stored manifest still -carrying one degrades to a single `[metadata_spec_invalid]` log line at -registration rather than a boot failure. - -D3 semantic entry `plugin-manifest-contributes-dead-members-retired`; no D2 -conversion, because a package manifest is not a stack collection member -(`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion -would be a transform with no seam that ever runs. diff --git a/.changeset/manifest-contributes-routes-retired.md b/.changeset/manifest-contributes-routes-retired.md deleted file mode 100644 index 582a31f905..0000000000 --- a/.changeset/manifest-contributes-routes-retired.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `contributes.routes` — the plugin-manifest block's last dead member (#10726, ADR-0049 enforce-or-remove; maintainer-ruled Option B 2026-08-22) - - - -**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). - -`contributes.routes` was the one member #10724's nine-member retirement -deliberately excluded: removing it needed a ruling, not a tombstone, because -the key was the only *declared* channel for a real capability (serving a -code-handler endpoint) and four published surfaces — a customer-published -skill among them — taught it as working machinery. The measurement (#10627, -controlled, three repos, cloud leg closed clean by #10812) is that nothing -ever read it: the HttpDispatcher never registered a prefix from the -declaration, so an entry parsed cleanly and served nothing. The maintainer -ruled Option B (remove; redirect the author-facing materials to the -imperative mount). The doc corrections landed first (PR #11327); this change -is the removal half, plus the two remaining teaching sites (#11328): the -worked manifest example in `plugin-rest-api.zod.ts` and the `router` -delivered-form comments in `metadata-plugin.zod.ts`. - -**What is refused:** authoring `contributes.routes`. It is a `retiredKey()` -tombstone (neither `ManifestSchema` nor the `contributes` object is -`.strict()`, so a plain deletion would have silently stripped the key), so -authoring it is a `tsc` error and a parse error carrying the prescription. - -**FROM → TO:** - -- `contributes.routes: [{ prefix, service, methods? }]` → mount the route - imperatively: resolve the `http.server` service from the plugin context and - register the handler on `kernel:ready`; delete the key. A declarative - endpoint over a pipeline the platform already runs (query/return records, - trigger a flow) is `defineStack({ apis })`. - -**What stays:** `contributes.kinds`, now the block's sole live member -(engine → `registry.registerKind`). Runtime behaviour is unchanged: nothing -ever read the key, so removing it removes no behaviour; a stored manifest -still carrying one degrades to a single `[metadata_spec_invalid]` log line at -registration rather than a boot failure. - -D3 semantic entry `plugin-manifest-contributes-routes-retired`; no D2 -conversion, because a package manifest is not a stack collection member -(`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion -would be a transform with no seam that ever runs. diff --git a/.changeset/manifest-kind-globs-retired.md b/.changeset/manifest-kind-globs-retired.md deleted file mode 100644 index 6a5c7e31c1..0000000000 --- a/.changeset/manifest-kind-globs-retired.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `contributes.kinds[].globs` — the declared file-type watch patterns nothing ever read (#11169, ADR-0049 enforce-or-remove; maintainer-ruled 2026-08-24) - - - -**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). - -**Removed key:** `manifest.contributes.kinds[].globs`. - -The schema promised that declaring `globs` "enables the system to parse and -validate new file types" (its own example: a BI plugin handling -`*.report.ts`). The promise was never kept: real glob-driven artifact -discovery reads `filePatterns` off the metadata type registry — which -`contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records -outright — so an authored `globs` was accepted, stored, served back through -`GET /metadata/kind`, and never consulted. Measured (PR #11168, re-verified -with positive control at claim): zero value reads anywhere; the only non-test -occurrences of the path were the schema declaration and two type positions. - -**FROM → TO:** `kinds: [{ id, globs: […], description? }]` → -`kinds: [{ id, description? }]` — delete the `globs` key; the kind's `id` and -`description` are unchanged and still register. The key is a `retiredKey()` -tombstone, so authoring it is a `tsc` error and a parse error carrying this -prescription. - -**What stays:** the `contributes.kinds` bucket itself and its `id` field -(live: engine → `registry.registerKind`, served via `GET /metadata/kind`). -File-type discovery remains single-channel on the metadata type registry's -`filePatterns`; if plugin-extensible discovery is ever wanted, it gets -designed against that registry, not revived here. The `registerKind` / -`getAllKinds` type positions drop `globs` (type-only; the parameter widens). - -D3 semantic entry `plugin-manifest-kind-globs-retired`; no D2 conversion (a -manifest is not a stack collection member — no seam would ever run it). diff --git a/.changeset/mapping-lookup-params-removed.md b/.changeset/mapping-lookup-params-removed.md deleted file mode 100644 index 678ef80708..0000000000 --- a/.changeset/mapping-lookup-params-removed.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the import mapping `lookup` transform's steering params (#10329, 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). - -`ImportFieldMappingSchema.params` declared four keys whose only stated purpose -was to steer the `lookup` transform — `object` ("Lookup Object"), `fromField` -("Match on"), `toField` ("Value to take"), `autoCreate` ("Create if missing") — -and the import path read none of them: `applyMappingToRows` handles `lookup` in -the same branch as `none` (the cell is copied through unchanged), and reference -resolution runs afterwards in `import-coerce.ts`, driven by the target field's -own metadata. Implementing them would be a second reference-resolution dialect -on the import path, which the code declines to build and the #10329 triage -ruling declined again. `autoCreate` was the one with teeth: it read as -"create the referenced record when nothing matches", and nothing was ever -created — with or without the key, an unresolved cell fails its row with -`import_reference_not_found`. - -FROM → TO, per key (all four are pure deletions — none ever had an effect to -lose, so removing them changes no import behaviour): - -- `fieldMapping[].params.object` → *(removed)*. The referenced object is the - target field's own declared `reference`. -- `fieldMapping[].params.fromField` → *(removed)*. The pipeline matches the - cell's display value against the referenced object itself. -- `fieldMapping[].params.toField` → *(removed)*. Resolution always writes the - referenced record's id. -- `fieldMapping[].params.autoCreate` → *(removed)*. Create or import the - referenced records first, then import the rows that point at them. - -One-line fix: delete the four keys (and any of their old alias spellings) from -`fieldMapping[].params`; `os migrate meta --from 17` lists the mechanical edits -for existing sources. - -The retirement kit: - -- `params` is a `strictObject`, so the keys are strict-deleted with a - `guidance` prescription per spelling — the four canonical keys and the - eleven ex-alias spellings (`lookupObject`/`targetObject`, - `match`/`matchOn`/`matchField`/`keyField`, `returnField`/`valueField`, - `create`/`createIfMissing`/`upsert`) all land on the full prescription - rather than a "did you mean" pointing at a key that is also gone (the - 17.0.0 #4509 treatment, one level down) -- ADR-0087 registration: the D2 conversion `mapping-lookup-params-removed` - (protocol 18), wired into the step-18 chain — `os migrate meta --from 17` - strips the four keys from stored `mappings[].fieldMapping[].params`. No - retired-key table entries: these keys sit one sub-walk level below the - authorable-surface drill (`data/ImportFieldMapping:params` is the walked - row, and it stays live), so there is no `defKey:name` row to register or - age out -- pin tests (`mapping.test.ts`): refusal pins per key asserting the - prescription (the `autoCreate` pin asserts the row-fails truth), alias - routing pins, and a surviving-surface pin (`value`/`valueMap`/`separator` - untouched) -- liveness ledger: `liveness/mapping.json`'s `fieldMapping` sub-walk boundary - note now records the retirement instead of parking the finding -- docs: the `import-mappings.mdx` warning about the inert params is deleted - along with the keys; the generated mapping reference no longer lists them diff --git a/.changeset/marked-refusal-classified-at-producer.md b/.changeset/marked-refusal-classified-at-producer.md deleted file mode 100644 index 655f72a39e..0000000000 --- a/.changeset/marked-refusal-classified-at-producer.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): a metadata app's MARKED refusal is classified as a refusal at the producer, instead of being wrapped as a store outage (#12536) - -A metadata app's sandboxed hook on `sys_metadata` can refuse a read and mark its -refusal with `userMessage` — the #9934 producer-side opt-in where the field's -presence *is* the marking. Every such refusal was handed to -`metadataStoreUnavailableError`, which builds a fresh `Error` carrying only -`code` / `status` / `cause`, and `declaredUserMessage` reads the **top level** -and never `cause`. The mark therefore died at the producer, on the whole -`getMetaItem` / `getMetaItems` read family and on `deletePackage`. - -**FROM** — a marked hook refusal and a `connect ECONNREFUSED` came out of -`getMetaItems` as the same envelope, and no consumer could tell them apart: - -``` -MARKED hook refusal -> 503 SERVICE_UNAVAILABLE declaredUserMessage=undefined - "The metadata store could not be read, ..." -driver fault -> 503 SERVICE_UNAVAILABLE declaredUserMessage=undefined - "The metadata store could not be read, ..." -``` - -**TO** — the failure is classified once, at the producer, before it is wrapped: - -``` -MARKED hook refusal -> 400 (or the status the hook declared for itself) - userMessage = the author's text, verbatim -driver fault -> 503 SERVICE_UNAVAILABLE (byte-for-byte unchanged) - "The metadata store could not be read, ..." -``` - -`deletePackage`'s per-item path follows the same classification: `failed[]` and -`cleanups[]` gain an optional `userMessage` member, present exactly when the -item's own failure declared the mark. Those rows ride inside a -`PACKAGE_DELETE_PARTIAL` 400's `details`, where no HTTP boundary can carry a -`userMessage` on their behalf, so the channel has to exist on the row itself. -`deleteMetaItem`'s two re-wrap exits carry the mark forward for the same reason -they already carry a catalogued `code`. - -Maintainer ruling 2026-08-27, option B. The declined alternative — forwarding -the mark *across* the 503 — is not implemented and is pinned against: the -store-unavailable door still quotes nothing from `cause`, and a test asserts the -underlying failure text appears in no field outside it. - -**Behaviour that does not change.** An unmarked failure keeps the #8136 503 -exactly as it was: same status, same code, same sentence, same `cause` -relocation. A blank, whitespace-only or non-string `userMessage` is not a -declaration, so nothing invents a marked refusal, and the #3821 generic -substitution stays in force for everything unmarked. - -**For hook authors.** A refusal that names its own `status` / `statusCode` -keeps it (#7867). One that names none is answered 400 — the same default the -REST sandbox door already applies to an undeclared hook refusal (#9967) — with -the catalogued code for that status; declare `status` (and a catalogued `code`) -when a different classification is wanted. diff --git a/.changeset/masterdetail-formtype-vocabulary.md b/.changeset/masterdetail-formtype-vocabulary.md deleted file mode 100644 index 632bbf5e57..0000000000 --- a/.changeset/masterdetail-formtype-vocabulary.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `ObjectMasterDetailFormPropsSchema.formType` narrows from bare `string` to the measured `simple | tabbed` (#11873 — the spec half of objectui#5939). - -**Newly rejected:** `wizard`, `split`, `drawer` and `modal` — each names an `object-form` renderer branch that breaks `object-master-detail-form`'s atomic parent+details contract (wizard mounts only the current step and turns the Save bar into Next; split persists via `dataSource.create` around the batch; drawer/modal move the parent half into a portal dialog the Save bar cannot submit). Each refuses with a per-value prescription; any other string (e.g. `wizzard`) now gets the plain enum refusal instead of parsing clean and rendering a silently sectionless parent form. - -**Write instead:** `simple` or `tabbed` — the two variants the renderer honours end-to-end for the parent half. For a wizard/split/drawer/modal presentation without inline details, author an `object-form`, whose `formType` keeps all six values. - -Breaking ships as minor per the launch-window convention (`scripts/check-changeset-no-major.mjs`). - - - diff --git a/.changeset/maxlength-adr0087-ledger-entry.md b/.changeset/maxlength-adr0087-ledger-entry.md deleted file mode 100644 index 22479142fd..0000000000 --- a/.changeset/maxlength-adr0087-ledger-entry.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The #11566 `maxLength` narrowing (shipped in 17.x: `z.number().int().min(1)`, refused outside `BOUNDED_STRING_FIELD_TYPES`) is now registered in the ADR-0087 migration ledger (#11950) — the enforcement PR deliberately deferred the entry because the registry file was serialized behind an in-flight change. Following the #8321 `scale`/`precision` template, the major-18 semantic entry carries both halves: the mechanical one (delete the key where it was misplaced — inert by construction outside the write-time validator's bounded-string branch) and the judgment one (a malformed value on a bounded-string type WAS consumed by the validator's raw comparison — `maxLength: 0` accepted only empty strings, a negative value refused every write — so only the author knows the bound they meant; the entry tells them to re-declare it). `objectstack migrate meta`, `spec-changes.json` and the upgrade guide surface the entry at the major boundary; no accept/reject behaviour changes in this release. - - diff --git a/.changeset/maxlength-bounded-string-only.md b/.changeset/maxlength-bounded-string-only.md deleted file mode 100644 index 37a28a18c8..0000000000 --- a/.changeset/maxlength-bounded-string-only.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`FieldSchema.maxLength` tightens on both axes (#11566, maintainer ruling 2026-08-24). Shape: the key is now `z.number().int().min(1)`, so `maxLength: 0`, negative and non-integer declarations are refused at parse — none of them is a character length, and `maxLength: 0` measurably sent the SQL schema-drift planner asking for `varchar(0)` DDL at severity error/destructive before #11431 taught that consumer to defend itself. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the write-time validator's ten — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code` — the only one of the three previously-disagreeing lists with a measured reader, exported as `BOUNDED_STRING_FIELD_TYPES`. - -What newly gets rejected: `maxLength: 0` / negative / non-integer on any type, and `maxLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms (`field.form.ts`, previously three types; `object.form.ts`, previously nine) converge on the same ten. Already-legal declarations (a positive-integer `maxLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. - -The ADR-0087 ledger entry for this narrowing is deferred to #11950 (the migration registry file was serialized behind an in-flight change when this landed), following the #8321 `scale`/`precision` template: a mechanical delete of malformed or misplaced values plus a semantic re-declare prescription at the next major boundary. diff --git a/.changeset/mcp-stdio-localization-after-settings-bind.md b/.changeset/mcp-stdio-localization-after-settings-bind.md deleted file mode 100644 index 4a67551268..0000000000 --- a/.changeset/mcp-stdio-localization-after-settings-bind.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -MCP stdio transport now serves the workspace's CONFIGURED timezone/locale -instead of the manifest defaults - -The stdio transport resolved its localization inside `MCPServerPlugin.start()`. -`SettingsServicePlugin` registers its service in `init()` but binds its data -engine from a `kernel:ready` hook registered in its own `start()`, and every -plugin's `start()` body runs strictly before the first `kernel:ready` handler — -so that read was inside the settings bind window under **every** composition -order. Being ordered after the settings plugin did not help, and the -`optionalDependencies` edge that repairs the neighbouring ordering defects would -not have moved it either. - -In that window the read does not fail: the empty in-memory fallback plus the -manifest defaults answer with `source: 'default'`, so `resolveLocalizationContext` -returned `UTC` / `en-US` and reported success, never reaching its direct -`sys_setting` fallback. The value is then held for the life of the transport by -design, so a long-lived stdio MCP server served every call with `UTC` / `en-US` -on a workspace whose persisted `localization` settings said otherwise, and never -self-corrected. - -The resolution now happens from a `kernel:bootstrapped` hook — the earliest phase -strictly after the bind, and the one `SettingsService.reportPreBindRead` names as -the remedy — memoized so it stays one resolution for the life of the transport -rather than a per-call settings read. A host that never fires the boot hooks -resolves it lazily at first use instead, so nothing can deadlock on a hook that -never arrives. - -**Behaviour change on a declared setting**: a deployment that has configured -`localization.timezone` / `localization.locale` / `localization.currency` will -see those values take effect on the stdio MCP surface, where it previously -always received the platform defaults. Formula evaluation (`ctx.timezone`) and -message localization on that surface change accordingly. diff --git a/.changeset/mcp-stdio-prebind-memoization-window.md b/.changeset/mcp-stdio-prebind-memoization-window.md deleted file mode 100644 index c16c9d4dd4..0000000000 --- a/.changeset/mcp-stdio-prebind-memoization-window.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -The stdio MCP transport no longer freezes a pre-bind localization when a data call races the boot. #11580 moved the localization read onto a `kernel:bootstrapped` hook, but its lazy entry point memoized whatever the FIRST read produced — and the transport goes live inside `MCPServerPlugin.start()`, before the remaining plugins' `start()` bodies and before every `kernel:ready` handler, which is where `SettingsServicePlugin` binds its data engine. A client fast enough to send a data call in that stretch resolved localization pre-bind, received `UTC` / `en-US` from the manifest defaults, and kept them for the life of the process — the narrow race turning into the permanent wrong value #11580 was about. - -The memo is now armed by the bind rather than by the first read: a resolution taken while the settings bind window is still open is answered but not kept, so a call arriving after the bind (even before `kernel:bootstrapped`) already sees the configured value, and the first resolution taken once the window has closed is the one that lives for the life of the transport. #7279's steady state is unchanged — one resolution, never a per-call settings read — because the `kernel:bootstrapped` hook takes that resolution the moment the window closes. A host that never fires the boot hooks (a bare kernel, a test harness) still answers rather than deadlocking, which is why the read is not made to wait for the bind. diff --git a/.changeset/messaging-inbox-authenticated-caller-scope.md b/.changeset/messaging-inbox-authenticated-caller-scope.md deleted file mode 100644 index 35f40ca4f3..0000000000 --- a/.changeset/messaging-inbox-authenticated-caller-scope.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -**Feature:** `MessagingService` gains a plugin-facing inbox write door scoped to the **authenticated caller** — `markReadAsCaller(caller, ids)` and `markAllReadAsCaller(caller)` (#10753). - -A plugin that pushes an "…awaiting your approval" message through `emit()` had no legitimate way to close it out again once the work was done, so the Console bell's unread badge stayed lit through a full page reload until the user hit "mark all read". The reporting project carries 30+ business hooks in that shape. - -What it was reaching for instead is the shape this closes. `markRead(userId, ids)` is the REST door's contract method (`INotificationService.markRead?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin ONE shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string. **Any plugin could mark any user's inbox messages read**, and the receipt lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check saw it either. This release is therefore both an API widening and the first tightening of in-process power on that path. - -The new pair takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId`, so "mark someone else's inbox read" has no spelling on this surface — it is unrepresentable rather than discouraged. That fits the case it was asked for exactly: the approver who clears a request *is* the recipient whose badge is stuck. - -`userId` is read, and nothing that merely resembles one: - -- `attributedUserId` is **attribution only** — its own contract states that nothing in the authorization path reads it, and a context carrying only it authorizes as anonymous (ADR-0118 D2). A `userId ?? attributedUserId` fallback would read as working and clear the wrong person's badge. -- `actor` is a service-principal label (`svc:`), not a `sys_user` id. -- `isSystem: true` with no user is refused rather than elevated: the system has no inbox to be the recipient of. - -Each refusal throws `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401` and the registered `code: 'UNAUTHENTICATED'` — and the refusal is evaluated **before** the empty-`ids` and no-data-engine short-circuits, which return `{ success: true, readCount: 0 }`. Reaching one of those with no authenticated caller would report success for a write that was never authorized, which is the silent-success shape this door exists to replace. - -Honest about what it is: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can write `sys_notification_receipt` directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. - -Nothing existing changes behaviour: `markRead` / `markAllRead` / `listInbox` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. diff --git a/.changeset/messaging-inbox-read-authenticated-caller.md b/.changeset/messaging-inbox-read-authenticated-caller.md deleted file mode 100644 index 752bec6948..0000000000 --- a/.changeset/messaging-inbox-read-authenticated-caller.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -**Feature:** `MessagingService` gains the plugin-facing inbox **read** door scoped to the authenticated caller — `listInboxAsCaller(caller, opts)` (#11452), completing the axis the write door (`markReadAsCaller` / `markAllReadAsCaller`, #10753) established. - -`listInbox(userId, opts)` is the REST door's contract method (`INotificationService.listInbox?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin one shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string, and the read lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check sees it either. Any plugin could read any user's inbox titles, bodies and read-state — the exact shape the write door closed, on the arguably more sensitive half: inbox bodies carry rendered business content. - -`listInboxAsCaller` takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId` through the same `resolveInboxRecipient` the write door uses — one refusal vocabulary, not two. `attributedUserId` (attribution only, ADR-0118 D2), `actor` (a service-principal label) and `isSystem` are refused rather than promoted, with `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401`, registered `code: 'UNAUTHENTICATED'`. The refusal is evaluated **before** `listInbox`'s no-data-engine / no-user short-circuit, which answers a well-formed empty `{ notifications: [], unreadCount: 0 }` inbox — the read-side analog of the silent success the write door replaced. The options window (`read` / `type` / `limit`) is forwarded unchanged. - -Honest about what it is, same as the write door: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can read `sys_inbox_message` rows directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. - -Nothing existing changes behaviour: `listInbox` / `markRead` / `markAllRead` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. `resolveInboxRecipient` gains an optional third parameter naming the target-user door its refusal prescribes; it defaults to the write door's existing text, so existing call sites keep their refusal bytes unchanged. diff --git a/.changeset/meta-delete-item-carriers.md b/.changeset/meta-delete-item-carriers.md deleted file mode 100644 index dbb8cd475e..0000000000 --- a/.changeset/meta-delete-item-carriers.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `meta.deleteItem` can pin a reset (`If-Match`) and discard only the pending draft (`?state=draft`) (#12181) - -Accept-set widening on a published SDK surface: both `deleteItem` declarations -— the unscoped `ObjectStackClient.meta` and the environment-scoped -`ScopedEnvironmentClient.meta` twin — take a third, optional -`DeleteMetaItemOptions` argument. Existing calls are unchanged: with the bag -omitted, the request is byte-identical to what this method has always sent -(no header key, no query string). - -FROM → TO: - -```ts -// FROM — the only reset a first-party SDK caller could express -await client.meta.deleteItem('view', 'shared_grid'); - -// TO — pin the reset against the version you read (ADR-0008 OCC) -const saved = await client.meta.saveItem('view', 'shared_grid', spec); -await client.meta.deleteItem('view', 'shared_grid', { ifMatch: saved.version }); -// concurrent edit → 409 metadata_conflict, instead of silently resetting it - -// TO — discard ONLY the pending draft; the published overlay keeps serving -await client.meta.deleteItem('view', 'shared_grid', { state: 'draft' }); -``` - -Why it matters: `DELETE /meta/:type/:name` has always read the `If-Match` -header and threaded it as `parentVersion` (the spec's own -`DeleteMetaItemRequest.parentVersion` describes the pin), and the sibling -first-party client `@object-ui/data-objectstack` `MetadataClient.reset` -already sent it — but this client had no argument for it, so every SDK reset -was last-write-wins on the one verb whose whole job is destroying an overlay -row. `state: 'draft'` reaches the NARROWER reset; without it the only -reachable reset was the full one, which drops the published overlay too. - -⛔ `?dropStorage=true` is deliberately NOT part of this bag. It is the one -carrier the reset door reads that ADDS destructive reach — it drops the -object's physical table — no caller was measured needing it from this client, -and the door's repeated-parameter refusal exists because of that -destructiveness. A caller that needs it is a separate, separately reviewable -widening. - -`state: 'active'` is the explicit spelling of the default and deliberately -sends nothing; an empty `ifMatch` (`''`) omits the header rather than pinning -against the empty string. diff --git a/.changeset/metadata-core-cjs-entry-point-loads.md b/.changeset/metadata-core-cjs-entry-point-loads.md deleted file mode 100644 index 402957ecd3..0000000000 --- a/.changeset/metadata-core-cjs-entry-point-loads.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/metadata-core": patch ---- - -fix(metadata-core): the CJS entry point loads again — `import.meta` is no longer emitted into `dist/index.cjs` (#12971) - -`@objectstack/metadata-core` declares `"type": "module"` with a dual `exports` -map, so `require('@objectstack/metadata-core')` is a published, supported entry -point. Since 17.2.0 it was **unloadable**: `resolveInstalledSpecVersion()` -anchors its `@objectstack/spec` lookup with `createRequire(import.meta.url)` — -correct for the ESM output — and tsup emitted that identifier **verbatim** into -`dist/index.cjs`. `import.meta` outside an ES module is a **parse-time** error, -so the module never began executing: - -``` -node -e "require('@objectstack/metadata-core')" -SyntaxError: Cannot use 'import.meta' outside a module -``` - -The failure was total, not partial. Neither the `typeof require === 'function'` -fast path above the line nor the `try`/`catch` around it ever ran, so **every** -CJS consumer and **every** code path in the package was affected, not just -callers of `resolveInstalledSpecVersion()`. Measured downstream: a walled -enterprise runtime refused to boot because `@objectstack/organizations` -resolves through this condition and the fail-closed tenancy wall correctly -refuses to serve when the organization wall cannot load. - -**The fix** is `shims: true` in this package's `tsup.config.ts` — the same one -line, for the same measured reason, already carried by `@objectstack/runtime` -and `@objectstack/metadata-protocol`. tsup rewrites `import.meta.url` in the -CJS output to a real `__filename`-derived URL, so both formats anchor on the -module's own file and resolve the same `@objectstack/spec/package.json`. Both -conditions now load and `resolveInstalledSpecVersion()` returns the identical -value in each. - -No API, type or behaviour change, and the ESM half is untouched — measured -rather than assumed: rebuilding with and without the shim, the emitted ESM code -bytes are identical in every file, and the whole difference is the shared -chunk's content-hashed **filename** (`chunk-DDDKWTSW.js` → `chunk-46MG4YHS.js`) -and the `sourceMappingURL` line naming it. Chunk names are internal to the -package; no `exports` target moves. Nothing an author writes moves either. - -**The class is now gated.** `pnpm check:dual-build-cjs-loads` (a step in the -required **Build Core** job) parses every emitted CommonJS file and `require()`s -every published `require` entry point in the workspace — 105 entries across 67 -packages — so the next package to leak ESM-only syntax into its CJS output -fails at the commit that introduces it rather than in a consumer's release. diff --git a/.changeset/metadata-fs-watcher-atomic-declared.md b/.changeset/metadata-fs-watcher-atomic-declared.md deleted file mode 100644 index 1db153b6eb..0000000000 --- a/.changeset/metadata-fs-watcher-atomic-declared.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/metadata-fs": patch ---- - -fix(metadata-fs): declare `startWatcher()`'s chokidar `atomic` option explicitly (#12696) - -`FileSystemRepository.startWatcher()` constructed its chokidar watcher with -`usePolling: true` but never passed `atomic`, leaving it to inherit chokidar's -default. That default is unconditionally `true` in the installed version -(chokidar 5.0.0): the defaults literal assigns `atomic: true` *before* the -caller's options are spread in, so chokidar's own default-correction -(`if (opts.atomic === undefined) opts.atomic = !opts.usePolling`) can never -fire — it only runs when `atomic` is literally `undefined` after the merge, -which it never is. The comment beside that correction ("Editor atomic write -normalization enabled by default with fs.watch") reads as "off under -polling"; the actual resolved behaviour was on regardless. - -This change passes `atomic: true` explicitly at the call site, with a comment -explaining why. **Patch, not a behaviour change**: verified at runtime -(constructing a watcher the way `startWatcher()` does and reading back -`watcher.options.atomic`) that the resolved value is identical before and -after — `true` either way, today. The only thing that changes is that the -value is now DECLARED rather than inherited from an upstream branch that -cannot execute, so a future chokidar release that fixes the ordering (making -the correction real) cannot silently flip this repository's watcher to -`atomic: false` under polling and change behaviour with no diff to review. - -Not addressed here (see #12696): whether `atomic: true` (the 100ms -unlink-coalescing deferral and the `DOT_RE` editor-temp-file matcher it turns -on) is actually the right value. No evidence surfaced that either has ever -affected a run; flipping it to `false` is a deliberate behaviour change to a -live delivery path that needs its own reverse verification, and is out of -scope for this card. diff --git a/.changeset/metadata-item-name-grammar-enforced.md b/.changeset/metadata-item-name-grammar-enforced.md deleted file mode 100644 index c9dd52ccb4..0000000000 --- a/.changeset/metadata-item-name-grammar-enforced.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) - -**BREAKING** accept-set narrowing at the metadata write door, shipped as -`minor` under the repo's launch-window convention for breaking changes. - -Metadata item names — the `name` half of the `type`/`name` pair that keys -`sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: -the empty string, `//`, `'Views/All Leads'` and slash-compound spellings -(`views/all_leads`) were all accepted and stored, and a slash in the name -bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz -name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). -Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. - -The grammar is now **declared in spec** (`MetadataItemNameSchema` / -`METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase -snake_case segments, optionally dot-qualified — the family -`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing -`ViewItemNameSchema` dotted declaration (same segment source, one grammar; the -view-item identity keeps requiring its dot). And it is **enforced at the -publish door** (`saveMetaItem` and `publishMetaItem` in -`@objectstack/metadata-protocol`): an off-grammar name is refused -`400 INVALID_REQUEST` with the grammar and the dotted prescription in the -message, and nothing is persisted. The slash bypass of -`refuseUnmintableMetaType` closes as a consequence. - -**What an author writes instead.** A flat snake_case name (`crm_lead`) and a -dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A -name that spelled a sub-resource with a slash (`views/all_leads`) is -re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity -whose prefix recovers the owner) or flattened with an underscore -(`views_all_leads`); containment is expressed by structure, never by a -separator inside the identity string. A translation item conventionally named -after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in -its required `locale` field (`"zh-CN"`), which has been the item's real -identity key all along. - -Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue -row remains listable and clearable. The in-repo stored corpus was measured at -**zero** slash-bearing item names (#12176 census, re-asserted at land time); -out-of-repo stored slash rows, if any exist, are reported by their deployment's -migrate run rather than rewritten silently. - - diff --git a/.changeset/metadata-overlay-read-cache.md b/.changeset/metadata-overlay-read-cache.md deleted file mode 100644 index 286b27908a..0000000000 --- a/.changeset/metadata-overlay-read-cache.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -feat(metadata-protocol): cache the `getMetaItems` overlay read, keyed on the engine write epoch (#11967) - -Leg D (ship-second) of the accepted #11633 cross-request caching design -(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0). - -`getMetaItems` re-read `sys_metadata` on every authenticated request. On the hot -path — `enforceApiAccess` → `loadObjectItems` → `getMetaItems({ type: 'object' })`, -once per REST request — that costs **two** sequentially awaited engine queries -whenever the environment holds no overlay rows for the type, because the empty -first result fires the alt-type retry. An app whose objects are all code-authored -paid both on every request. That read is now cached behind invalidation that is -synchronous and in-process rather than TTL-bound. - -- **Primary trigger — the #11968 engine write epoch**, read structurally rather - than imported: `@objectstack/metadata-protocol` does not depend on - `@objectstack/objectql`, and the substrate declared `WriteEpochLike` separately - for exactly this kind of consumer. Every `sys_metadata` write reaches it, because - `SysMetadataRepository` writes through `engine.insert/update/delete`. -- **Residual bound — `OS_METADATA_OVERLAY_CACHE_TTL_MS`**, default 30s, `0` = off - and a real path. It bounds one thing only: a peer replica's write on a deployment - with no `authz.invalidated` bridge attached. -- **A success is cached ONLY when the engine exposes the write epoch** — leg C's - rule, re-measured and unchanged here. No seam means the cache declines rather - than degrading to a TTL-only shape, which for this leg would make - publish-visibility a timer. Every existing test double keeps its exact query - multiset; only a real engine caches. - -**Grade: `minor`, argued.** Not `patch`: this adds a supported deployment knob -(`OS_METADATA_OVERLAY_CACHE_TTL_MS`, registered in the canonical environment-variable -table) and introduces a bounded staleness window that a multi-node operator must be -able to read about before upgrading — a release note that said only "internal -performance" would under-describe it. Not `major`: no public API changes, no export -is added or removed, `getMetaItems`' request and response contracts are untouched, -and the pins assert the cached answer is identical to the uncached one, so no caller -can observe the difference except in query count. Same grade and same reasoning as -leg C (#11966), which shipped `minor` for the same knob-plus-window shape. - -**The SchemaRegistry-hydration trap (#11633 §4 leg D) is resolved structurally, not -by care.** `getMetaItems` registers overlay rows back into the SchemaRegistry as a -side effect of the read, so a cache that skips the read would quietly stop populating -the registry. What is cached here is the overlay **row set** — the value *upstream* of -the hydration branch — never the merged answer downstream of it. Every consumer of -those rows still runs on every call, hit or miss: the overlay parse, the package-aware -merge, `hydrateOverlayIntoRegistry`, the MetadataService merge, the disabled-package -filter, the nav contributions and the decorations. That containment also keeps the -cache's reach co-extensive with what its key can validate — the SchemaRegistry, the -MetadataService and the artifact table are all mutable sources the write epoch cannot -observe, and none of them is being cached. diff --git a/.changeset/metadata-protocol-specifier-pin-11350.md b/.changeset/metadata-protocol-specifier-pin-11350.md deleted file mode 100644 index d37a609494..0000000000 --- a/.changeset/metadata-protocol-specifier-pin-11350.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Pin the declaration emitter's module specifier for `FormFieldInput` to `@objectstack/spec/ui` (#11350). When #11350 made the three ui/automation input types nameable from `@objectstack/spec`'s root entry, tsc's declaration emitter for this package switched its synthesized reference for `FormFieldInput` from the `/ui` slice to the root entry — both portable, but the root specifier pulls spec's entire root module graph into every downstream TypeScript program that reads this package's declarations (measured: +190k types, +805k instantiations, roughly +560MB on one real program). A local type-only import binding keeps the emitted reference on the narrow `/ui` entry. Type-only and erased at runtime: every emitted JS file is byte-identical; the package's public export surface is unchanged. diff --git a/.changeset/migrate-multi-value-columns-command.md b/.changeset/migrate-multi-value-columns-command.md deleted file mode 100644 index 27abd9d454..0000000000 --- a/.changeset/migrate-multi-value-columns-command.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/driver-sql": minor ---- - -New operator-run command `os migrate multi-value-columns`: migrates a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — the `manual_column_type_change` drift `os migrate apply` reports and deliberately never reconciles for you (#11535, ruled C on #11700). Flags: `--apply` (default off), `--yes`/`-y`, `--force`, `--table ` (repeatable), `--database-url`, `--json`. **Dry-run contract: without `--apply` the command executes nothing at all** — it prints the exact statements and the database they would run against, opens no seam and issues no probe, and a run is verified to have left the column type and every row unchanged. `--apply` runs `@objectstack/driver-sql`'s own `manualJsonConversionSql` — newly re-exported from that package's index for this consumer, its only other change — i.e. the statement the drift finding itself prints (Postgres: one `ALTER … USING (CASE …)` with `json_build_array`; MySQL: the two row-shaping `UPDATE`s then `ALTER … MODIFY … json`), refuses to execute anything the finding does not contain verbatim, re-runs detection afterwards and exits non-zero if the finding has not cleared. SQLite is excluded — the stale column round-trips a real array there, so the finding is never raised. Rows corrupted before the column is migrated are out of scope, and the command is never invoked automatically: nothing on the boot path reaches it. diff --git a/.changeset/migrate-refuses-unloadable-host-config.md b/.changeset/migrate-refuses-unloadable-host-config.md deleted file mode 100644 index 032c20f1d3..0000000000 --- a/.changeset/migrate-refuses-unloadable-host-config.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os migrate plan` / `apply` exit non-zero when the host config exists but could not be loaded (#12953) - -A host `objectstack.config.{ts,js,mjs}` that EXISTS and throws while loading — a -missing environment variable is the ordinary cause, and ObjectStack Cloud's own -control-plane config throws without `AUTH_SECRET` — used to warn loudly and then -**exit 0**. The object set the commands diffed on that path is the data stack -plus the platform floor: nine tables, none of them the deployment's, and `0` -drift over them printed "Physical schema is in sync with metadata — nothing to -migrate". Measured on the fixture this ships with, before the change: `plan`, -`plan --json`, `apply --yes` and `apply --yes --json` all returned `0`. - -Maintainer ruling 2026-08-29, verbatim 「同意」: a green exit over an UNMEASURED -partial metadata set is the false-green a migration tool must never emit, and -the population this "regresses" was computing defective plans all along. Both -commands now exit **non-zero** on that path, with an error on stderr naming the -config file, the underlying failure, and the remedy. - -**BEHAVIOUR CHANGE to exit status**, shipped as `minor` under the repo's -launch-window convention. It is scoped to exactly one shape, and the two -neighbouring ones were measured byte-identical before and after — stdout *and* -stderr, human and `--json`, for both commands: - -- host config **present and unloadable** → non-zero (this change); -- host config **absent** → unchanged, still exit 0. `hostConfigLoaded` is - `false` on that shape too, so the refusal keys on `hostConfigPath !== null` - rather than on the flag alone; -- host config **present and loadable** → unchanged, still exit 0. - -Everything the previous behaviour emitted is kept, deliberately: the loud stderr -warning, and the `composition.hostConfigLoaded` discriminator in the `--json` -payload that consumer coverage gates (objectstack-ai/cloud#1705) read — a table -count cannot replace it, because the platform floor raises the count either way. -The refusal changes the exit STATUS, not the document: the whole plan, or the -whole JSON payload, is still written before the process exits non-zero, and the -unloadable path's payload is byte-identical to the one it emitted before. - -**Migration.** A CI step that runs `os migrate plan`/`apply` against a project -whose config needs environment it was not given now fails instead of reporting -success over a fraction of the deployment. Supply that environment to the run -(the error names the missing variable), or fix the config. diff --git a/.changeset/minlength-bounded-string-only.md b/.changeset/minlength-bounded-string-only.md deleted file mode 100644 index 6856591ead..0000000000 --- a/.changeset/minlength-bounded-string-only.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`FieldSchema.minLength` tightens on both axes (#11949, maintainer ruling 2026-08-25) — `maxLength`'s twin defect pair (#11566), closed with the same template. Shape: the key is now `z.number().int().min(1)`, so `minLength: 0`, negative and non-integer declarations are refused at parse. The lower bound is 1 by ruling: "no minimum" is expressed by omitting the key, not by declaring a vacuous truth — `minLength: 0` can never fail, and a permanently-true declaration is exactly the noise an AI metadata author mass-produces, so it is refused loudly at authoring time. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the `BOUNDED_STRING_FIELD_TYPES` set — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (twelve members since #11875) — the same set `maxLength` converged on. - -What newly gets rejected: `minLength: 0` / negative / non-integer on any type, and `minLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms converge on the same set (`field.form.ts` previously showed the key for three types; `object.form.ts` for nine). Already-legal declarations (a positive-integer `minLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. - - diff --git a/.changeset/mongodb-boolean-aggregand-ruled-answers.md b/.changeset/mongodb-boolean-aggregand-ruled-answers.md deleted file mode 100644 index 6512270601..0000000000 --- a/.changeset/mongodb-boolean-aggregand-ruled-answers.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/driver-mongodb": patch ---- - -fix(driver-mongodb): a boolean aggregand answers the ruled values (#11151) - -`sum` and `avg` over a **boolean** column answered `0` and `null` on this -driver, where every SQL dialect (#11635), `driver-memory` (#11065) and -objectql's in-memory fallback already answered `3` and `0.5` over the same -3-true/3-false rows. The lowering passed the boolean straight to MongoDB's -`$sum` / `$avg`, which are arithmetic accumulators and ignore every non-numeric -value: with nothing numeric to fold, `$sum` returns its identity `0` and `$avg` -returns `null`. Both arms now wrap the aggregand in the boolean-only `$cond` -coercion #11065 landed, so a rate measure over a flag column reads the same on -this driver as on the others. - -**⛔ `min` / `max` are deliberately NOT coerced.** They are order statistics -over BSON canonical comparison order, which ranks booleans and returns a member -of the input domain — #11249 ruled they answer `false` / `true`, and coercing -them would have answered `0` / `1`, breaking that contract in the opposite -direction from the defect being fixed. Their lowering is unchanged; a pin reads -the emitted stages to keep it that way. - -**The coercion stays boolean-only.** `null`, a missing key and a non-numeric -string reach the accumulators exactly as before and stay excluded. Widening to -the other half of objectql's `toNumber` — which maps a non-numeric string to -`0` — would average garbage as zero rather than excluding it, a separate -question this change does not open; a control pins the exclusion. - -**Why `patch` and not `minor`.** This changes what an existing operation -returns, which ordinarily argues for `minor`. It is graded `patch` because the -returned values were **already ruled** before this change (#11065 for the -arithmetic pair, #11249 for the order statistics) and are stated as shared -values in `@objectstack/spec/data`; every other face already produced them, and -the sibling repair on `driver-memory` shipped as a patch. There is no new API, -no option, and no opt-out to describe — nothing here is a feature, and the only -behaviour a consumer could have depended on is a value this project has ruled -wrong and that no other driver produces. Calling it `minor` would advertise a -capability that does not exist and imply the old answer had standing. - -Not user-visible, and shipped in the same change because the two are one cell: -`mongodb-pipeline-evaluator.testkit.ts` — the server-free instrument that holds -this lowering to the shared table — applied its "arithmetic accumulators ignore -non-numeric values" filter to `$min` / `$max` as well, one arm too far, and so -answered `null` for them over a boolean column while the lowering under test was -correct. Those arms now ignore only null and missing, compare by BSON canonical -order, and refuse a type the evaluator does not rank instead of silently -answering `null`. diff --git a/.changeset/mongodb-retired-agg-arms-refused.md b/.changeset/mongodb-retired-agg-arms-refused.md deleted file mode 100644 index dd1cdeb4a4..0000000000 --- a/.changeset/mongodb-retired-agg-arms-refused.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/driver-mongodb": patch ---- - -fix(driver-mongodb): refuse the retired `array_agg` / `string_agg` instead of lowering them (#13075) - -`buildAccumulator` still carried `case 'array_agg'` and `case 'string_agg'` -arms — both lowering to `$push` — plus a matching `string_agg` join in -`postProcessAggregation`. Both names left `AggregationFunction` at **#6188** -under ADR-0049 enforce-or-remove, and both SQL faces have refused them as -class-1 undeclared names ever since (`driver-sql`'s `refuseAggregateFunction`, -`driver-turso`'s `RemoteTransport`, each `INVALID_QUERY` / **400**). -`driver-mongodb` was the only face still answering them, so **one query got a -400 on two backends and a `$push` array on the third** — the local/remote fork -#5907 exists to prevent, one vocabulary later. - -Why this face kept them when `objectql`'s in-memory fallback deleted its arms -for the same two names at #6188: that fallback switches on the **enum type**, so -`case 'array_agg'` there stopped type-checking the moment the value left the -enum. `AggregationInput.function` here is a bare `string` — the driver's own -`aggregate` reads aggregations through an `any` cast — so these arms compiled -fine and survived the retirement unnoticed. - -Both names now answer `INVALID_QUERY` / **400**, answer-for-answer parity with -both SQL faces. They are named explicitly rather than left to fall through. -When this change was written, falling through was not safe at all: -`buildAccumulator`'s `default` arm answered `{ $sum: … }`, so deleting the arms -alone would have turned a visibly-wrong ARRAY into an arithmetically PLAUSIBLE -NUMBER — strictly the worse failure, and exactly the defect #13076 has since -fixed in that arm (#12818). Naming them was correct whichever order the two -landed in, and now that #13076 is on `main` the named arm still draws the -distinction `AggregationFunction`'s own error map draws: a caller who bypassed -the parse door is told the name was **removed** at #6188, which is a different -fact from `default`'s "is not a declared aggregate function". Both producers are -kept for that reason. - -The retirement prescription itself is not restated here — it lives once, on the -enum's error map in `@objectstack/spec`, and a copy in the driver would be a -second wording of one vocabulary with nothing keeping the two in step. - -**Graded `patch`, deliberately.** No correct query's answer moves: all six -declared functions are byte-identically unchanged, pinned by a positive control -in the same suite that walks `AggregationFunction.options`. `AggregationNodeSchema` -already rejects both spellings at the parse door, so the only callers whose -behaviour changes are ones reaching the exported builder or the driver's -`aggregate` directly — and they were reading a value the protocol has no name -for. Nothing to migrate: read the rows with an ordinary `fields` query and shape -them in the caller, or model the roll-up as a stored field. diff --git a/.changeset/mysql-boolean-row-read-presentation.md b/.changeset/mysql-boolean-row-read-presentation.md deleted file mode 100644 index e41af7172c..0000000000 --- a/.changeset/mysql-boolean-row-read-presentation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): a declared `Field.boolean` answers JSON booleans on MySQL's row-read doors (#11782) - -`formatOutput`'s boolean read coercion — and its per-column mirror -`readPresentationKind`, which `distinct()` and the aggregate group-key / -`min`/`max` tracking consume — was gated `isSqlite`-only. On MySQL the storage -is `tinyint(1)` and mysql2 hands back a JS number, so a declared boolean -answered `1`/`0` through `find()`, `distinct()` and aggregate group keys while -SQLite and Postgres answered `true`/`false` — and, after #11635 presented -aggregate `min`/`max` on every dialect, `max(flag) === true` and -`row.flag === 1` disagreed on the same column over the same MySQL connection. - -Measured on live MySQL 8.0.46 before the fix: `find().flag` → `1` (`typeof -number`), `distinct('flag')` → `[0, 1]`, aggregate group keys → `1`/`0`. The -boolean presentation now runs on the two dialects whose stored boolean is a -number (SQLite `INTEGER` 0/1, MySQL `tinyint(1)`); Postgres stores a real -`boolean` node-pg already parses, so it deliberately stays outside the gate and -its answers are byte-identical. A `NULL` boolean stays `null` on every door -(absence is not `false`), and declared `number`/`string` columns are untouched. diff --git a/.changeset/mysql-row-byte-budget-diagnostic.md b/.changeset/mysql-row-byte-budget-diagnostic.md deleted file mode 100644 index a50bf82ebb..0000000000 --- a/.changeset/mysql-row-byte-budget-diagnostic.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -MySQL's row-size refusal now names the declarations that caused it (#11565). MySQL charges every bounded column's DECLARED byte width against a per-row budget, independently of the per-column `varchar` ceiling — measured on 8.0.46 through this driver, 15 fields at `maxLength: 1024` create and 16 are refused — and its own error names no column and no declaration, about a table its author described entirely in metadata. Schema sync now translates `ER_TOO_BIG_ROWSIZE` at both the `CREATE TABLE` and `ALTER TABLE ADD COLUMN` sites into the same failure re-worded: every varchar column the object produces, widest first, with its emitted width and its byte cost at the schema's real bytes-per-character (read from the server, not assumed), plus the fields that reach the budget while declaring nothing — `lookup`, `user`, `auto_number` and the option types all take `varchar(255)`. InnoDB's separate per-page limit answers with the same code and is reported with the number the server quoted rather than 65535. Deliberately a translator and not a pre-flight: it speaks only after the server has refused, so it cannot refuse an object MySQL would have accepted. Nothing is refused that was accepted before, and no other dialect is touched. diff --git a/.changeset/namespace-retirement-now-cited.md b/.changeset/namespace-retirement-now-cited.md deleted file mode 100644 index 0483dfd8ff..0000000000 --- a/.changeset/namespace-retirement-now-cited.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `namespace` rejection messages now cite the decision record that actually exists: ADR-0129 D3 (the enforced object-naming contract — `name` is the canonical id, module prefix embedded literally, no separate namespace declaration). The object tombstone's citation, removed when its previous spelling pointed at a decision letter no ADR declared, comes back pointing at the real record; the translation-contract rejection re-points the same way. The retirement itself is unchanged and still enforced. diff --git a/.changeset/nine-parents-keep-details.md b/.changeset/nine-parents-keep-details.md deleted file mode 100644 index ea4eaadb69..0000000000 --- a/.changeset/nine-parents-keep-details.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor ---- - -`FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent). - -Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling. - -There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save. - -`@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch. diff --git a/.changeset/non-unique-keyed-text-remainders.md b/.changeset/non-unique-keyed-text-remainders.md deleted file mode 100644 index fefc02e2fd..0000000000 --- a/.changeset/non-unique-keyed-text-remainders.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -Make the last two non-unique keyed text indexes expressible on MySQL — remove one, -narrow one - -`driver-sql` emits a keyed text-family column as `varchar(maxLength)` only when the -declared bound is one MySQL can key (768 characters on utf8mb4, the 3072-byte key-part -ceiling); otherwise the column stays `TEXT`, MySQL refuses it as an index key -(`ER_BLOB_KEY_WITHOUT_LENGTH`), and the object's whole `syncSchema` fails — it lands -registered with its declared index absent. #11374 declared sourced bounds for thirteen -such columns and #11627 carried the over-long UNIQUE ones on a SHA-256 hash-shadow -column, taking live MySQL 8.0.46 from 12/44 → 8/44 → 2/44 failing objects. - -The two that remained are **non-unique**, and a hash shadow structurally cannot serve -them: a UNIQUE constraint is an equality-only predicate that survives hashing exactly, -but a non-unique index exists for an access path, and an index over a digest -accelerates no `WHERE col = ?` the planner can reach without rewriting the read side. -They are ruled separately (maintainer, 2026-08-25) because they are different problems: - -- **`sys_verification.value` — the declared index is removed.** The column is - genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code - payloads there as a JSON blob), and the index was measured dead: better-auth 1.7.1 - keys every verification lookup on `identifier`, `id` or `expiresAt` - (`internal-adapter.mjs`), upstream declares the field unindexed and unbounded, and no - in-repo query filters `sys_verification` by `value`. An index that silently does not - exist on one dialect is the worst of both worlds; removing it makes the metadata match - reality. -- **`sys_oauth_client_resource.resource_id` — the declared bound narrows 1024 → 768.** - This one is a live access path (the FK side of `sys_oauth_resource.identifier`, read - as a predicate by upstream's client-registration collision path), so it keeps its - index and becomes keyable instead. 768 is the widest utf8mb4 value a MySQL key part - holds, and the smallest narrowing that works. - -This is an enforcement change on published objects — hence the minor grade. On MySQL and -SQL Server a `resource_id` longer than 768 characters is now refused rather than stored, -and on PostgreSQL and SQLite the `sys_verification` `[value]` index is dropped on the -next schema sync (on MySQL it never existed). Neither narrows what the producing -contract can emit: the value is an RFC 8707 resource-indicator URI, and upstream -better-auth 1.7.1 stores that same identifier as `varchar(255)` on MySQL -(`get-migration.mjs`) and this referring column as `varchar(36)`, so a resource whose -identifier exceeded 768 characters could never have been registered upstream at all. - -The pin that enumerated the package for unbounded keyed text columns now also rejects a -non-unique index over any text column MySQL cannot key, so a third member of the class -fails at test time rather than on a live server. Its `UNBOUNDABLE` allowlist — which -existed to excuse `sys_verification.value` — is empty as a result, and a synthetic -control keeps the excusing branch exercised rather than letting it rot. diff --git a/.changeset/nosql-index-unique-scope-docs.md b/.changeset/nosql-index-unique-scope-docs.md deleted file mode 100644 index f19fcdcb70..0000000000 --- a/.changeset/nosql-index-unique-scope-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Document why `NoSQLIndexSchema.unique` stays a bare boolean instead of the ADR-0120 unique-scope vocabulary carried by `FieldSchema.unique` and `IndexSchema.unique`: the schema is a raw NoSQL driver-configuration descriptor below the tenancy seam — nothing materializes indexes from it, and the one NoSQL driver that creates indexes (driver-mongodb) consumes the object-level `indexes[]` surface (which already carries the vocabulary) and is explicitly single-tenant (#3724) — so a scope word here would be declarable-but-inert vocabulary (ADR-0078). The `describe()` and docblock now state the deliberate omission and the condition under which `UniqueScopeSchema` should be adopted, so the asymmetry with the other two `unique` surfaces is not mistaken for drift (#11215). diff --git a/.changeset/notification-keyed-text-bounds.md b/.changeset/notification-keyed-text-bounds.md deleted file mode 100644 index cf758475cb..0000000000 --- a/.changeset/notification-keyed-text-bounds.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -**Fix:** every keyed text column across the five `sys_notification_*` objects declares a sourced `maxLength`, so on MySQL the indexes they key are expressible **as declared** instead of the current mixed state — the UNIQUE constraints (above all `sys_notification_delivery`'s `(notification_id, recipient_id, channel)` dedup key) carried on #11627 hash-shadow columns, and every plain text-keyed index refused with a schema-sync error on each boot (#12978, the #11374 route-A class). - -The bounds and their producers (each stated in the declaration): `notification_id` / `recipient_id` / `user_id` 255 (the referenced physical id column, `varchar(255)`); `channel` 64 (registered `MessagingChannel.id` machine vocabulary, per the `sys_session.revoke_reason` precedent); `topic` 200 (= `sys_notification.topic`, the event topic these values are matched against); `digest_key` 331 (= recipient 255 + `|` + channel 64 + `|` + window date 10); `principal` 520 (widest declared selector arm `owner_of:OBJECT:ID` = 9 + 255 + 1 + 255); `locale` 16 (= `sys_email_template.locale`, the sibling BCP-47 declaration). - -**Operator-facing consequences.** Additive schema-sync never rewrites an existing column, so what changes depends on the deployment: - -- **New databases (all dialects):** the columns are created `varchar(n)` and every declared index is created directly — the dedup UNIQUE key is 255+255+64 = 574 chars = 2296 utf8mb4 bytes, inside InnoDB's 3072-byte key budget. The two wide organization-scoped UNIQUEs (`sys_notification_preference` 774 chars, `sys_notification_subscription` 975 chars) still exceed that budget on MySQL and remain carried by the #11627 SHA-256 hash shadow — enforced, with the NULL-organization caveat tracked as #12998. -- **Existing databases, Postgres/SQLite:** the declared indexes already existed (the refusal is MySQL-only) and no drift op is emitted for a bounded text field over a physical TEXT column (`narrow_varchar` deliberately fires only against a wider varchar — #11431; measured, with duals, on #12978). Boot behaviour is unchanged. What changes is the write seam: a value longer than the declared bound is now **refused loudly** instead of stored (`declared = enforced`; these identifier-family ceilings are storage-owned, #12144). -- **Existing databases, MySQL:** the columns stay TEXT. Boot-time index sync keeps re-attempting the declared indexes: the UNIQUEs stay carried by the #11627 hash shadow (created on the first boot under a post-#11627 build — unless pre-existing duplicate rows make the shadow ALTER fail loudly, in which case deduplicate first), and each **plain** text-keyed index is still refused, logged at error level by schema-sync on every boot; the object stays registered and served. This is today's behaviour, not a new refusal — what this change adds is that the refusal's remedy becomes real: `os migrate` has **no arm** that rewrites TEXT to `varchar(n)` and never truncates, so the operator route is a hand `ALTER TABLE ... MODIFY` of the named columns to their declared widths, after which the next boot creates every declared index directly. Take a backup first; restate `NOT NULL`/`DEFAULT` on MySQL `MODIFY`; run under `STRICT_TRANS_TABLES` (the default), where an over-long stored value fails the ALTER with `ER_DATA_TOO_LONG` instead of being truncated — pre-flight with `SELECT COUNT(*) FROM sys_notification_delivery WHERE CHAR_LENGTH(channel) > 64` (and likewise per column) to find such rows first. The artifact boot-migration gate is unaffected: this change emits no `destructive` drift entry (the missing-index finding is `create_index`, category `safe`). - -Graded `minor` for the same reason as the #11374 emitter changeset: on newly created tables the declared bound is now physically enforced where the dialect enforces `varchar`, and at the write seam everywhere, so a write longer than the bound that previously landed in unbounded TEXT is refused — the declaration becoming enforced, named here as a behaviour change. diff --git a/.changeset/notify-node-template-locale-is-not-per-recipient.md b/.changeset/notify-node-template-locale-is-not-per-recipient.md deleted file mode 100644 index fbc4435b45..0000000000 --- a/.changeset/notify-node-template-locale-is-not-per-recipient.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-automation': patch -'@objectstack/service-messaging': patch ---- - -The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient - -`NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` -path resolves `(name, locale)` with **one** locale for the whole notification. -The same retired promise survived outside the spec file, in the places an app -author is most likely to read it: - -- `service-automation/src/builtin/notify-node.ts` — the `template` field's - `configSchema` description, i.e. the text rendered in the **Studio form** the - author fills in. It said the row is "resolved by (name, recipient locale) at - delivery time and rendered per recipient". -- `content/docs/automation/email-templates.mdx` — the only site that stated the - conclusion outright rather than merely licensing it: "so one node mails each - person in their own language". -- `service-messaging/src/messaging-service-plugin.ts` — the channel-registration - log line, which advertised "resolve sys_email_template per recipient locale". -- Two internal comments in `notify-node.ts` and one in its test, describing the - payload the outbox snapshots as carrying a per-recipient-locale resolution. - -None of that is what the delivery path does. `payload.locale` is interpolated -**once, before fan-out**, so it is a single value for the whole notification, and -its fallback is the deployment default (`II18nService.getDefaultLocale()`). The -platform has no per-user locale to read — `sys_user` carries no locale column, -and request-scoped locale does not exist at async delivery time — so recipients -whose personal languages differ all receive the same template row. A per-user -locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers -in as an override at that same seam when it lands; the corrected wording dates -the deferral so it reads as a decision with provenance rather than an oversight. - -The gap was worth correcting because the wording licensed exactly one action — -convert `notify` nodes on the belief that non-English recipients get non-English -mail — and that action is a **net regression**: `TEMPLATE_*` failures classify -`permanent` and dead-letter, and the inbox channel starts requiring an email -service with `renderTemplate()` where inline text needed none. - -Text only: no schema accepts or refuses anything it did not before, no delivery -behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` -asserts the form description names `payload.locale` and the deployment default -and refuses a bare "recipient locale", so a later edit cannot quietly restore the -promise. diff --git a/.changeset/notify-template-locale-is-deployment-default.md b/.changeset/notify-template-locale-is-deployment-default.md deleted file mode 100644 index 8b04d7a0c3..0000000000 --- a/.changeset/notify-template-locale-is-deployment-default.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale - -The `notify` node's localizable path (`template` → a `sys_email_template` bundle) -was documented in `packages/spec/src/automation/io-node-config.zod.ts` as -resolving `(name, recipient locale)` **per recipient** at delivery time, and the -`template` `.describe()` added that it "renders subject/body per recipient". -Read plainly — and it is the text a consuming app's author reads — that says the -recipient's own language selects the template row. - -It does not, and deliberately does not. The delivery path -(`service-messaging/src/email-channel.ts`) has said so honestly at its own -`getDefaultTemplateLocale` all along: the platform has no per-user locale -(`sys_user` carries no locale column), and request-scoped locale -(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async -delivery time, so "recipient locale" resolves to the **deployment default**, -`II18nService.getDefaultLocale()` — the same ruled source the auth emails use. -The one lever is `payload.locale`, and that is interpolated **once, before -fan-out**, so it is a single value for the whole notification at all three -`channel.send` call sites (`fanOut`, the outbox single-delivery path, and -`processDigestGroup`). - -The gap mattered because the wording licensed exactly one conclusion — "convert -the nodes and non-English users get non-English notifications" — which is false, -and acting on it is a **net regression**: `TEMPLATE_*` failures classify -`permanent` and dead-letter, and the inbox channel starts requiring an email -service with `renderTemplate()` where inline text needed none. So the drift was -not a cosmetic imprecision; it was an instruction to make a change that loses -deliveries. - -Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side — -a per-user locale is deferred until measured pull — so the prose is the side that -moves. All five "recipient locale" sites in the file now name the resolved value: -the schema doc block, the `template` field's JSDoc and `.describe()`, and both -`superRefine` refusal messages. Each says the locale is `payload.locale` if the -producer set one, else the deployment default, and that it is **one value per -notification, not one per recipient**, with the 2026-08-13 deferral dated in -place so the limitation reads as a decision with provenance rather than a -permanent property of the design — a per-user locale layers in as an override at -that same seam when it lands. - -Text only. No schema accepts or refuses anything it did not before, no delivery -behaviour moves, and no wire value changes — `packages/spec` publishes -`src/**/*.zod.ts` and the generated reference page, so the corrected wording -ships to consumers reading either. The pins in -`io-node-config.test.ts` that asserted the old `/recipient locale/` string now -assert the qualification itself, and refuse a bare "recipient locale", so a -future edit cannot quietly restore the promise. diff --git a/.changeset/oauth-resource-identifier-sourced-255.md b/.changeset/oauth-resource-identifier-sourced-255.md deleted file mode 100644 index a64c47db7a..0000000000 --- a/.changeset/oauth-resource-identifier-sourced-255.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/platform-objects": minor ---- - -fix(platform-objects): source `sys_oauth_resource.identifier`'s bound from its producer — 1024 → 255, and the referring column with it (#12313) - -**BREAKING** accept-set narrowing on two published objects, shipped as `minor` -under the repo's launch-window convention for breaking changes. - - - -`sys_oauth_resource.identifier` declared `maxLength: 1024`. That number cited no -producer — it arrived with the object wholesale (#3080) as generous slack for -"a URI". Every other bound in the #11374 family names where it came from; this -one had no comment at all. - -Measured, the producing contract cannot fill it. better-auth 1.7.1 is the sole -writer (`managedBy: 'better-auth'`, `protection.lock: 'full'`) and emits this -column as **`varchar(255)`** on MySQL: `oauthResource.identifier` is declared -`{ type: 'string', required: true, unique: true }`, and `getType` in -`better-auth/dist/db/get-migration.mjs` takes the `field.unique → 'varchar(255)'` -arm of its mysql string branch. Verified by running that generator against live -MySQL 8.0.46 and reading `information_schema.COLUMNS` as its own query: -`varchar(255)`, 1020 octets under utf8mb4. - -**The dead end this closes.** #11701 had already narrowed the REFERRING column -`sys_oauth_client_resource.resource_id` to 768 so its declared index could exist -on MySQL at all. The two halves of one foreign key then disagreed about what a -legitimate resource identifier is. On PostgreSQL or SQLite — neither of which -has MySQL's key-width ceiling — an operator could register a resource whose -`identifier` was 900 characters, because the referent's contract admitted it, -and then no client could ever be granted that resource, because the referrer -refused it. Registration succeeded, authorization failed forever, silently. -Both columns now declare **255**, so referent and referrer admit exactly the -same domain. - -**What the narrowing rejects.** Values in **(255, 768]** move from "the referrer -accepts" to "both refuse"; values in (768, 1024] were already refused by the -referrer and are now refused by the referent too. Nothing upstream can produce -either band — the sole writer stores the identifier in `varchar(255)`. - -**Hash-shadow outcome, measured rather than predicted.** 255 × 4 = 1020 bytes -sits under `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` (768 characters / 3072 bytes), -so `sys_oauth_resource` **LEAVES** the #11627/#12198 hash-shadow route it was on -at 1024. Both readings are from `information_schema` on live MySQL 8.0.46: - -| | before (1024) | after (255) | -|---|---|---| -| `identifier` physical | `text` (65535 octets) | `varchar(255)` (1020 octets) | -| shadow column | `uniq_sys_oauth_resource_identifier__hash varbinary(32)` present | **absent** | -| UNIQUE index keys on | the shadow column | `identifier` directly, `SUB_PART NULL` | - -The declared uniqueness is unchanged and still enforced over the full value — -the index is a direct full-value UNIQUE, not a prefix index. Deployments that -already synced this table on MySQL will see the shadow column dropped and the -UNIQUE index rebuilt directly on the narrowed column at the next schema sync. - -**A correction to the #11701 citation.** That comment stated upstream emits the -referring column as `varchar(36)` via `getType`'s `field.references` arm. It -does not: `resourceId` participates in table-level indexes, so `getType` -receives a `tableIndexStringLength` argument, which takes precedence over every -`field.*` arm, and `getDatabaseIndexStringLength` seeds its reduce at MySQL's -191-character default — measured, upstream emits **`varchar(191)`**. That 191 is -an artifact of upstream's index budget on upstream's own physical schema; -ObjectStack emits its own schema, so the referring column takes the REFERENT's -255, the same derivation `client_id` already uses. diff --git a/.changeset/object-editmode-declared.md b/.changeset/object-editmode-declared.md deleted file mode 100644 index 2ea81b81ac..0000000000 --- a/.changeset/object-editmode-declared.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408) - -Accept-set **widening** — no existing document changes meaning and nothing is -removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out -family): objectui's shipped runtime reads `objectDef.editMode` (record-edit -routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG -announces the key to authors, while the spec's strict parse rejected it with -`unrecognized_keys` — so an author following objectui's documentation was -refused by every spec-validating path and the key only worked through data -sources that skip validation. - -The object document now declares it beside the other display hints -(`nameField`, `highlightFields`, `stageField`): an optional cross-renderer -edit-interaction intent — `'modal'` opens the edit form as a dialog over the -current view, `'page'` navigates to a dedicated full-page edit route, absent -lets the renderer pick its own default (objectui defaults to modal). Values -outside the enum are rejected as a located value error at `editMode`. - -Consumer-side follow-up (not in this change): objectui retires its -`ObjectSchemaClientExtensions.editMode` client-extension member and lets the -spec derivation carry the key — its pinned rejection tests flip by design. -That retirement is **release-gated** on the `@objectstack/spec` release -containing this change (per the recorded ruling), not merely on this merge. diff --git a/.changeset/object-grid-data-view-data-converged.md b/.changeset/object-grid-data-view-data-converged.md deleted file mode 100644 index 694454e796..0000000000 --- a/.changeset/object-grid-data-view-data-converged.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ComponentPropsMap['object-grid'].data` converges onto `ViewDataSchema` (#12039, objectui#6207 Option A) - -**BREAKING** accept-set change on one props-map entry, shipped as `minor` under -the repo's launch-window convention for breaking changes. - -Two spec authorities disagreed on the KIND of `object-grid`'s `data`: -`ViewDataSchema` — the authority objectui#5090 ruled the registry declaration -against, and what `ObjectGridSchema.data` resolves to — is an object -discriminated on `provider` (`object` / `api` / `value` / `schema`), while -`ComponentPropsMap['object-grid'].data` said `z.array(z.unknown())`. Measured -on `@objectstack/spec@17.2.0`: `{ provider: 'value', items: [] }` — the -pinned-legal form — was refused by the props-map entry while the bare array -parsed. Maintainer ruling (2026-08-25, objectui#6207, Option A): the props-map -entry converges onto `ViewDataSchema`; the bare-array form is the deprecated -`staticData` shortcut the objectui#4648 carve-out already refuses to publish. - -Migration — FROM → TO, one wrapping object: - -```ts -// before (refused now) -data: [{ id: 1, title: 'Inline row' }] -// after -data: { provider: 'value', items: [{ id: 1, title: 'Inline row' }] } -``` - -The ruled migration check ran with the change: the sweep of generated -artifacts, templates and first-party corpora (examples/, skills/, -create-objectstack, spec fixtures) found zero bare-array `data` authors, so no -rewrite ships. `staticData` (the legacy bare-array shortcut the renderer still -reads) keeps its shape but is not the prescription. - -`ComponentPropsMap['element:number'].filter` (the sibling key of #12039 / -objectui#6206) is NOT changed here: the ruling's binding measurement-first -precondition measured the pinned adapter/runtime refusing the raw -`ViewFilterRule[]` form on the element's primary (analytics) read path, which -forks that key back to triage. objectui#6206 remains open. - - diff --git a/.changeset/object-grid-default-sort-retired.md b/.changeset/object-grid-default-sort-retired.md deleted file mode 100644 index 42a3c93619..0000000000 --- a/.changeset/object-grid-default-sort-retired.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `object-grid`'s legacy `defaultSort` fallback (#11805, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`, per the maintainer's #11805 -ruling — 「不需要major」; the migration prescription is registered under -protocol major 18, where `os migrate meta` users will look). - -`ObjectGridPropsSchema.defaultSort` was the legacy second spelling of `sort`: -a single `{ field, order }` pair the renderer read only when `sort` was absent -— measured at the `.objectui-sha` pin (`190fbd01d`), -`plugin-grid/src/ObjectGrid.tsx:1244-1246` (the `$orderby` fetch fallback) and -`:2847`, where the header-arrow path wraps it `[schema.defaultSort]`, the -exact array shape `sort` carries. One intent, two spellings; objectui's mirror -schema is parity-test-only and parses nothing at runtime, so only this -strictObject can refuse the legacy spelling (objectui#5861 retires the -renderer's reads as the consumer half, on its own schedule). - -FROM → TO: - -- `defaultSort: { field, order }` (no `sort` beside it) → - `sort: [{ field, order }]` — the same pair, wrapped in the array shape every - read path honours. -- `defaultSort` beside an authored `sort` → *(removed)*. The renderer's own - precedence made the fallback unread there, so the deletion is lossless. - -One-line fix: rename the key to `sort` and wrap the value in an array; -`os migrate meta --from 17` lists the mechanical edits for existing sources. - -The retirement kit: - -- `retiredKey()` tombstone in `ObjectGridPropsSchema` — authoring the key is a - tsc error (`never`) and a parse error carrying the wrap-and-rename - prescription (the surface baseline line carries `[RETIRED]`) -- ADR-0087 registration: `ui/ObjectGridProps:defaultSort` in - `RETIRED_KEYS_BY_MAJOR[18]`, and the D2 conversion - `object-grid-default-sort-removed` (protocol 18) wired into the step-18 - chain — wrap-and-rename when `sort` is absent, a pure strip when `sort` is - present -- pin tests (`component.test.ts`): a refusal pin asserting the prescription, a - no-materialize pin, and a surviving-surface pin on `sort` -- zero authored occurrences in either repo's corpora (the card's measurement, - re-run at dispatch), so no in-repo source changes ride along diff --git a/.changeset/object-useractions-view-key-guidance.md b/.changeset/object-useractions-view-key-guidance.md deleted file mode 100644 index ba1a7e37a4..0000000000 --- a/.changeset/object-useractions-view-key-guidance.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): wrong-layer guidance for `group` / `hideFields` / `rowColor` on the object `userActions` block (#11459) - -`data/object.zod.ts`'s object-level `userActions` block already curated -`sort` / `search` / `filter` / `editInline` with a pointer at the VIEW -`userActions` block they actually belong to. `group` / `hideFields` / -`rowColor` — adopted onto the view's vocabulary at #11195 — shared the same -wrong-layer trap but got only the generic unknown-key rejection with an -edit-distance suggestion, which has nothing useful to offer over the object -block's `create`/`import`/`edit`/`delete`/`exportCsv` shape. - -**Nothing changes about what parses.** All three keys were rejected on the -object block before this change and are rejected after it; only the message -gains the same curated pointer the other four wrong-layer keys already carry. diff --git a/.changeset/objectql-custom-sql-measure-refusal.md b/.changeset/objectql-custom-sql-measure-refusal.md deleted file mode 100644 index 650d0c3343..0000000000 --- a/.changeset/objectql-custom-sql-measure-refusal.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -The ObjectQL analytics strategy now refuses a custom-SQL measure (`AggregationMetricType` `number` / `string` / `boolean`) with a loud `400 INVALID_FIELD` naming the measure and its metric type, instead of forwarding the raw SQL expression into `engine.aggregate` — where `driver-sql` rejected it blaming a `function` key the author never wrote, and the in-memory evaluator silently answered `null` for every bucket under the measure's own name. - -What stops being served, and for whom: on deployments whose driver has no native SQL capability (the ObjectQL aggregate path — e.g. Mongo or in-memory), a query or dataset widget selecting a custom-SQL measure now answers a 400 that says to use an aggregate measure (count/sum/avg/min/max/count_distinct) or run the cube on a native-SQL driver. Those queries previously "succeeded" with a per-bucket `null` (or a mis-attributed driver error), never with a correct number. Native-SQL driver behaviour is unchanged: custom-SQL measures still run there, emitted verbatim. diff --git a/.changeset/objectql-engine-getschema-typed.md b/.changeset/objectql-engine-getschema-typed.md deleted file mode 100644 index 0a72f9d22c..0000000000 --- a/.changeset/objectql-engine-getschema-typed.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': patch ---- - -`IObjectQLEngine.getSchema` now returns `ServiceObject | undefined` instead of `unknown` (#12481) — the #11833 ruling's fork 3 as executed by #12248, applied one member over by inheritance: `ObjectQL.getObject` is literally `getSchema`'s alias (`return this.getSchema(name)`), the class has always answered `ServiceObject | undefined`, and `ServiceObject` lives in spec (`data/object.zod.ts`), so the contract's "engine-local type" rationale for `unknown` no longer applied here either. FROM `getSchema(objectName: string): unknown` TO `getSchema(objectName: string): ServiceObject | undefined` (authored state, ADR-0122, matching `getObject`). Consumers reading `managedBy` / `fields` / `userActions` off the answer no longer need a cast or a private structural re-declaration; `plugin-security`'s engine-owned write guard drops its now-redundant `as EngineOwnedSchemaLike | undefined` narrowing (behaviour unchanged). Implementations conforming to the class's actual behaviour are unaffected; a fake answering a non-conforming shape now fails compile at the member instead of drifting silently. diff --git a/.changeset/objectql-per-measure-aggregate-filter.md b/.changeset/objectql-per-measure-aggregate-filter.md deleted file mode 100644 index 03aaa3a6fc..0000000000 --- a/.changeset/objectql-per-measure-aggregate-filter.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -**Fix:** `/api/v1/analytics/query` on the ObjectQL door (MongoDB, the memory driver, or any deployment whose driver reports `objectqlAggregate` but not `nativeSql`) now honours a measure's own scoped `filter` — `won_count` and `won_amount`-style conditional measures answer the same numbers the dashboard door and the native-SQL door already did (#10413 phase 2). - -`ObjectQLStrategy.execute` lowers each measure's `filter` into the ONE aggregation it belongs to, via the per-aggregation `filter` field #10576 added to `engine.aggregate`'s contract (SQL `FILTER (WHERE …)` semantics) — not into the whole-call filter, which would have narrowed every measure (a fix shaped that way would make a conditional measure right while making every unconditional sibling measure in the same query wrong). An aggregation with no measure filter is unchanged and keeps the native-pushdown-eligible shape. - -`ObjectQLStrategy.generateSql` (the `/analytics/sql` echo) renders the same conditional aggregate — `COUNT(CASE WHEN … THEN … END)`-style — so the preview stays an honest description of what `execute()` now actually runs, matching the native-SQL strategy's existing echo for the same class of measure. - -Phase 1 (PR #10758) already ANDed a dataset's definition-level `filter` into the whole-call filter on this door; this closes the remaining half of the two-door disagreement #10413 reported. `NativeSQLStrategy` (#10298 / PR #10411) is unaffected by this change. diff --git a/.changeset/objectql-privileged-reads-join-ambient-transaction.md b/.changeset/objectql-privileged-reads-join-ambient-transaction.md deleted file mode 100644 index c4f9c445cb..0000000000 --- a/.changeset/objectql-privileged-reads-join-ambient-transaction.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -**Fix:** the engine's three privileged driver-level reads now JOIN an open ambient transaction instead of asking the connection pool for a second connection — which deadlocked `pool max=1` datasources and made `/admin/remove-user` refuse an entitled, signed-in caller with `401 UNAUTHENTICATED` (#10792). - -`resolveSecret`, `resolveSecretField` and `resolveInternalField` read at DRIVER level on purpose: that is the only layer where a masked or `internal: true`-omitted value still exists, and bypassing hooks, field-level security and sharing is the declared trust each of them places in its in-process caller. What they also bypassed — not by design — was the connection the surrounding transaction is holding. `buildDriverOptions` threads the ambient handle (ADR-0034) onto every ordinary read for exactly this reason; these three passed the driver **no options at all**, so their read went to a *fresh* pooled connection. - -On a roomy pool that is invisible: the pool simply hands out another connection. On a single-connection pool it is a deadlock. SQLite's knex pool is `max: 1` — `driver-sqlite-wasm` and `driver-sql`/better-sqlite3 both — and `pool max=1` is not a tuning choice there, it encodes SQLite's single-writer model. - -Measured on the erasure path, which is where the two met. `AuthManager.handleRequest` runs the `SESSION_ERASURE_PATHS` routes inside `engine.transaction(...)` so a refused erasure cannot leave the session and account deletes committed. Inside that transaction the vendor's session re-read reaches `resolveInternalField` through plugin-auth's internal-field readback; the read waited for a connection that could not be freed until the transaction waiting on the read finished, knex's acquire timeout fired (`Timeout acquiring a connection. The pool is probably full`), and the route degraded the block into an authentication refusal. On the default `objectstack dev` datasource, before this change: a caller better-auth's own admin gate **admits** was answered `401` after **120,196 ms** with the target row still present, and a signed-in plain member got the same `401` after **120,025 ms** instead of the `403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` an authorization refusal owes them. After: `200` with the row deleted, `403`, and an anonymous caller's `401` unchanged — all promptly. Postgres and MySQL (`max >= 10`) always conformed and are unaffected; the reach nonetheless mattered because SQLite is the default datasource for `objectstack dev`, the showcase/dogfood boot, and any self-host that has not configured Postgres or MySQL. - -Two properties are deliberately **not** widened. The join is reads-only — the privileged write paths are untouched. And the #5351 same-origin gate still decides whether the handle is this object's driver's to use, so a privileged read that resolves to a *different* datasource keeps its own connection rather than executing someone else's statement on the wrong one. diff --git a/.changeset/olive-crabs-clone.md b/.changeset/olive-crabs-clone.md deleted file mode 100644 index c48fd29259..0000000000 --- a/.changeset/olive-crabs-clone.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -Add `POST /api/v1/automation/:name/clone` — whole-definition flow clone (ADR-0126 §7.1) - -An admin who cannot edit a packaged flow in place can now copy it to an ordinary -org-authored sibling and edit that instead. `POST /automation/:name/clone` takes -`{ name, label }` — both mandatory — and registers a copy of the source flow's -parsed definition under the new machine name. - -**The copy is whole-definition, never an enumerated facet list.** Every key the -source definition carries comes across; exactly `name`, `label` and `status` are -mutated. This is the shape ADR-0126 §7.1 rules for, and the reason is measured: -a clone assembled from an enumerated facet list silently dropped three of six -facets (#11703) — the record was created, the success toast fired, and the -difference was discoverable only by diffing the two rows. A flow has far more -facets than a permission set, so the acceptance test asserts deep equality of -the cloned definition against its source minus those three fields; a dropped -facet fails the test rather than shipping. - -**The new machine name is mandatory and a same-name clone is refused** with a -409 `RESOURCE_CONFLICT` naming both the reason and the remedy. Not because -storage rejects it — storage legitimately holds both rows — but because the -automation engine keys flows by bare name, so a second definition under one name -silently shadows the other and which of the two dispatches depends on -registration order. - -**No ancestry is recorded.** Nothing tracks what a clone was copied from — no -provenance field on the definition, none on the response (ADR-0126 amendment -ruling 2, §9). The source's own ADR-0010 protection envelope (`_packageId`, -`_provenance`, `_lock`, …) is dropped rather than carried across, so the clone is -an org-owned flow the admin can actually edit rather than a second copy of the -package's locked artifact. - -**References are not re-pointed** — no reference index exists, so the clone calls -exactly what the original called. The response says so, along with the fact that -`status: 'draft'` is a lifecycle label and not an off-switch: the engine only -disables on `obsolete`/`invalid`, so a cloned record-change or schedule flow is -bound to its trigger and runs alongside the flow it was copied from. - -The route joins the `manage_metadata` authoring-write set (#10145/#10243) — it -registers flow metadata at environment scope, exactly as `POST /automation` does. diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md deleted file mode 100644 index c9949bae28..0000000000 --- a/.changeset/olive-donkeys-repeat.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): classify a refusal by what the producer declared, not by how its message starts - -The three record-share routes and `POST /api/v1/analytics/dataset/query` each -built their error answer by hand and shared no branch with the door every -`/data` face reports through, so one refusal got a different wire answer -depending on which route caught it. Both now ask that door first, through a -single new seam (`classifiedRefusalAnswer`), for a refusal the **producer -classified** — a declared 4xx `status`/`statusCode` **plus** a `code`, or a -sandboxed hook body's business `throw`. Everything else is untouched. - -**Per route, old answer → new answer.** Check your error handling if you branch -on any of these. - -`GET /api/v1/data/:object/:id/shares`, `POST` the same path, and -`DELETE /api/v1/data/:object/:id/shares/:shareId`: - -| the thrown refusal | was | is now | -| :--- | :--- | :--- | -| `{ code: 'RECORD_LOCKED', status: 409 }` (any code outside the five prefixes) | `500` `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` | `409` `RECORD_LOCKED` | -| `{ code: 'FORBIDDEN', status: 403 }` — `plugin-sharing`'s own write gate | `500` `SHARE_*_FAILED` | `403` `FORBIDDEN` | -| the same declared as `statusCode` rather than `status` | `500` `SHARE_*_FAILED` | the declared status + code | -| a sandboxed hook refusal, no status declared | `500` `SHARE_*_FAILED`, message = the QuickJS wrapper `hook '' threw: Error: ` | `400` `VALIDATION_ERROR`, message = the hook's own sentence | -| a sandboxed hook body that CRASHED | `500` `SHARE_*_FAILED`, message = the wrapper around `TypeError: …` | `500` `SHARE_*_FAILED`, message = `Internal server error` | -| `VALIDATION_FAILED:` / `PERMISSION_DENIED:` / `NOT_FOUND:` / `CONFLICT:` / `SHARING_NOT_ENABLED:` prefixed messages | 400 / 403 / 404 / 409 / 422 with the prefix stripped | **unchanged** | -| anything else | `500` `SHARE_*_FAILED` with its own message | **unchanged** | - -`POST /api/v1/analytics/dataset/query`: - -| the thrown refusal | was | is now | -| :--- | :--- | :--- | -| a sandboxed hook refusal, no status and no code declared | `500` `{ code: 'ANALYTICS_QUERY_FAILED', error: }` | `400` `{ message: }` — the same status `POST /api/v1/data/:object` answers for the identical throw, and no code, because the producer declared none | -| a declared 4xx + code spelled `statusCode` rather than `status` | `500` `ANALYTICS_QUERY_FAILED` | the declared status + code | -| a declared 4xx + code spelled `status` | the declared status + code | **unchanged** | -| a declared 5xx, a crashed hook body, a driver fault, anything unclassified | `500` `ANALYTICS_QUERY_FAILED` | **unchanged** | - -The nested `{ success: false, error: { code, message } }` envelope the sharing -family answers is unchanged — only the status and code inside it move. The -`VALIDATION_ERROR` on the sandbox row is the catalog's declared floor for a -required `code` the producer did not name (`standardErrorCodeForHttpStatus`); -the flat `/data` body omits `code` there instead, because its `code` is -optional and ADR-0112 invents nothing. diff --git a/.changeset/one-id-shaped-platform-admin-judge.md b/.changeset/one-id-shaped-platform-admin-judge.md deleted file mode 100644 index 4b1acb0bab..0000000000 --- a/.changeset/one-id-shaped-platform-admin-judge.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-auth": patch ---- - -**Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). - -ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. - -**What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: - -- an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; -- a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; -- an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. - -**One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. - -**The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. - -Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. diff --git a/.changeset/openapi-info-version-is-the-api-version.md b/.changeset/openapi-info-version-is-the-api-version.md deleted file mode 100644 index 57868de115..0000000000 --- a/.changeset/openapi-info-version-is-the-api-version.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -`GET {basePath}/openapi.json` no longer falls back to the spec package's -compile-time version when `api.version` is configured empty - -The served `info.version` has always carried the API version identifier -(`api.version`, default `'v1'`), under a comment claiming it carried "the -runtime version so consumers don't pin to the spec package's compile-time -version". Both halves were false: the runtime version never reached the field, -and the `|| enriched.info.version` fallback published exactly the compile-time -version the comment said the line existed to avoid. - -The fallback was reachable rather than dead, though not because the contract -permits it: `RestApiConfigSchema` declares -`version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which refuses `''`. Nothing -parses this config against that schema — both hops into the server are casts — -so `normalizeConfig`'s `??` is the only guard, it does not catch `''`, and the -document advertised `@objectstack/spec`'s package version. It now serves the configured value as -written, so a misconfigured deployment stays visibly misconfigured instead of -silently switching the field to a different kind of fact. Every non-empty -`api.version` — including the default — serves exactly what it served before. - -`info.version` is deliberately not the runtime version: OpenAPI 3.1 defines it -as "the version of the OpenAPI document (which is distinct from the OpenAPI -Specification version or the API implementation version)". Callers who want the -serving artifact read `{basePath}/discovery` or `/health`. diff --git a/.changeset/openapi-served-info-version-passes-through.md b/.changeset/openapi-served-info-version-passes-through.md deleted file mode 100644 index 936920474a..0000000000 --- a/.changeset/openapi-served-info-version-passes-through.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -`GET {basePath}/openapi.json` no longer overwrites `info.version` — the served -document publishes the version `packages/spec` put in the artifact - -**FROM** the deployment's declared API version identifier (`api.version`, which -`normalizeConfig` defaults to `'v1'`) — **TO** the published artifact's own -version (`@objectstack/spec`'s `./openapi.json` export, `17.2.0` at the time of -this change, set from that package's version by `build-openapi.ts`). - -The `info` block is the half of the document `packages/spec` produces and owns, -and this route's own test twin has asserted that "serve-time enrichment must not -touch it" since #5588 — with the assertion narrowed to `info.title` alone, -precisely because `version` was overridden. The invariant was stated and then -excepted, in the same file. The override is deleted, the exception is gone, and -the twin's assertion now covers the whole `info` block. - -Nothing is lost. The declared API version identifier still exists and is still -observable where a caller can act on it: it builds the mount -(`${basePath}/${version}` gives `/api/v1`). The runtime version is still -answered by `{basePath}/discovery` and `/health`. OpenAPI 3.1 defines this field -as "the version of the OpenAPI document (which is distinct from the OpenAPI -Specification version or the API implementation version)" — the document being -served is the artifact, so its version is the artifact's. - -**Measured consumer pull: zero.** No consumer reads this document's -`info.version` by value, and nothing derives a route prefix from it or compares -it against `api.version` — the repo's one route-prefix derivation -(`packages/core/src/qa/http-adapter.ts`) reads the config directly and is -unaffected. That sweep covers `objectstack` only: `objectui`, `cloud` and -`cloud-v1` were not reachable where it ran, so the zero across those three is an -earlier reading carried forward, not re-measured here. - -The one deployment shape that changes is one scraping the served `info.version` -to learn its own `api.version`; it should read the mount, or -`{basePath}/discovery`, both of which state that fact on purpose. - -This supersedes the `info.version` half of the unreleased -`openapi-info-version-is-the-api-version` entry in the same cycle: that entry's -fallback removal stands, its statement that the served field carries the API -version identifier does not. diff --git a/.changeset/operation-message-refusal-keys.md b/.changeset/operation-message-refusal-keys.md deleted file mode 100644 index 7c1ae3a582..0000000000 --- a/.changeset/operation-message-refusal-keys.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): Operation Message Catalog gains two refusal-situation keys — -`record_write_denied` and `approval_recall_not_submitter` (#12493, the -spec-side half of the shape-A ruling on #11993) - -`BUILTIN_OPERATION_MESSAGES` gains two situation keys in all four platform -locales (`en`, `zh-CN`, `ja-JP`, `es-ES`), rendering through -`renderOperationMessage` with the same override/locale/fallback behaviour as -the `record_access_denied` family, addressed for deployment overrides as -`errors.record_write_denied` and `errors.approval_recall_not_submitter`: - -- `record_write_denied` — the user can see this record, but changing or - deleting it is beyond their access (the sharing middleware's by-id write - denial; one key for both write verbs). Deliberately NOT - `record_access_denied` restated: that sentence would be false on a row the - read path already admitted. -- `approval_recall_not_submitter` — the user asked to recall an approval - request someone else submitted; the copy names who CAN act (the submitter, - or an administrator — the #3424 admin override). - -Both sentences take no placeholders, re-derived per site as the family -requires: the only nameable facts at the measured throw sites are object API -names and opaque ids, which must not reach a toast. - -**Operator-visible consequence, stated plainly:** this release ships the -catalog keys only — two refusal situations gain localized copy and a -deployment-override address. The emitters still throw their raw English -strings (`FORBIDDEN: insufficient privileges to …` in the sharing -middleware, `FORBIDDEN: only the submitter may recall this request` in -plugin-approvals) until their consumer halves land separately (#12260 and -#11993). Nothing changes on the wire in this release; once the consumer -halves land, end users see these refusals in their own locale, and any -deployment `translation` defining the two `errors.*` keys takes effect then. diff --git a/.changeset/operation-message-zhcn-fullwidth-comma.md b/.changeset/operation-message-zhcn-fullwidth-comma.md deleted file mode 100644 index 3a8a23d506..0000000000 --- a/.changeset/operation-message-zhcn-fullwidth-comma.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): punctuate the `zh-CN` Operation Message Catalog with the full-width comma (#12717) - -Every `zh-CN` entry in `BUILTIN_OPERATION_MESSAGES` separated its clauses with -U+002C (the ASCII comma) while ending the same sentence with U+3002 (the -full-width period) — two punctuation systems inside one sentence. In Chinese -typography the half-width comma reads as a lapse rather than as a style, and -these are strings a business user reads in a toast. - -The two surfaces genuinely sit together on one screen: an approval action's own -description renders full-width (`plugin-approvals`' -`zh-CN.objects.generated.ts`, U+FF0C / U+FF1F) while a refusal from the same -feature rendered ASCII. - -Twelve U+002C across the nine `zh-CN` entries — the five situation keys -(`permission_denied`, `record_access_denied`, `record_change_not_allowed`, -`record_write_denied`, `approval_recall_not_submitter`) and the four -`delete_restricted*` variants — become U+FF0C. Nothing else moves: the U+3002 -sentence finals were already correct, the two U+3001 (、) enumeration commas in -the `_required` variants are correct Chinese and stay, and the `en`, `ja-JP` and -`es-ES` rows are untouched. - -Text-face only — no key, placeholder, wire code, status or accept/reject -behaviour changes. A deployment that overrides `errors.` through a -`translation` is unaffected; only the built-in default moves. diff --git a/.changeset/orange-planets-sniff.md b/.changeset/orange-planets-sniff.md deleted file mode 100644 index 716b608f6c..0000000000 --- a/.changeset/orange-planets-sniff.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': minor ---- - -`OrgScopingEntitlement` grows two per-deployment wall-shaping keys, both declared by the mounted `org-scoping` runtime and consumed by plugin-security when arming the Layer 0 organization wall, both fail-closed (absent ⇒ byte-identical behaviour): - -- `platformGlobalObjects?: readonly string[]` — objects THIS deployment declares platform-global; Layer 0 does not wall them here (read filtering, the ADR-0123 D2 no-active-org write refusal, the forge guard, and the Layer 1 wildcard-`organization_id` policy drop all follow, because they read the same per-object security meta). Exact machine names only; a junk shape is refused loudly and exempts nothing. -- `suppressUnboundedOrgAdminGrant?: boolean` — the walled-posture `organization_admin` auto-grant hands out `organization_admin_no_bypass` (no unbounded `viewAllRecords`/`modifyAllRecords`) instead; the superseded-variant reconcile converges standing grants in both directions. - -New spec exports: `PlatformGlobalObjectsSchema`, `PlatformGlobalObjects`, `OrgScopingEntitlementSchema`. diff --git a/.changeset/org-membership-limit-unbounded.md b/.changeset/org-membership-limit-unbounded.md deleted file mode 100644 index 2362aae4aa..0000000000 --- a/.changeset/org-membership-limit-unbounded.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/types': patch ---- - -An organization no longer stops accepting members at 100 — membership is not a -limited axis, and the ceiling nobody chose is now stated explicitly - -A customer adding users was refused with `Organization membership limit -reached`. Nothing in this codebase set that ceiling: better-auth's organization -plugin substitutes a vendor default of **100** for an absent `membershipLimit` -(`count >= (membershipLimit || 100)` in `routes/crud-members`), and -`auth-manager` passed `organizationLimit` — how many organizations one user may -CREATE — while never passing `membershipLimit`, which is a different question. - -The two read almost identically in a config block and mean nothing alike, which -is why the gap survived: the option that WAS set looked like the option that -was not. In the field the refusal is worse than merely wrong — it arrives while -an operator is looking at licences and seat counts, and reads as an entitlement -problem on an axis that carries no entitlement at all. Seats are metered on AI -usage; plain membership has never been billed. - -- `membershipLimit` is now passed explicitly, defaulting to unbounded. -- `OS_ORG_MEMBERSHIP_LIMIT` is the opt-in for a deployment that DOES want a - ceiling (a pilot, a trial tenant). Unusable values (empty, non-numeric, - zero, negative) read as unset rather than as a cap — a typo must not be the - thing that locks an organization, which is exactly the failure mode being - fixed. -- The decision lives in `resolveMembershipLimitOption()` rather than inside the - plugin-construction expression, so it is testable: the unset case, the - explicit ceiling, the unusable-value direction, and — deliberately — that the - chosen value clears the vendor's 100 by a wide margin. If a future - better-auth changes that default, the test says so instead of leaving an - unexplained constant behind. - -The unbounded value is `Number.MAX_SAFE_INTEGER`, not `Infinity`: the option is -compared numerically but also travels through option plumbing that may assume a -finite value, and nine quadrillion members is unlimited by any measure that -reaches a real deployment. diff --git a/.changeset/org-scoped-presentation-capability.md b/.changeset/org-scoped-presentation-capability.md deleted file mode 100644 index e3c8409b4e..0000000000 --- a/.changeset/org-scoped-presentation-capability.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-core": minor -"@objectstack/rest": minor -"@objectstack/runtime": minor ---- - -feat(security,meta): org-scoped presentation authoring capability `manage_org_presentation` (#12702) - -A tenant org admin in a walled posture can now be granted org-scoped authoring -of exactly the org-overridable presentation types (ADR-0005 tier A: view / -dashboard / report / translation / email_template today — the registry is the -authority) without holding platform-wide `manage_metadata` (maintainer -direction 2026-08-27, quoted in #12701). - -- **spec**: new curated `PLATFORM_CAPABILITIES` entry `manage_org_presentation` - (`scope: 'org'`), seeded into `sys_capability` at boot like its siblings. - Granted by NO shipped permission set — the SaaS operator grants it per - deployment, so existing postures (`single` included) are byte-unchanged by - its existence. -- **metadata-core**: new `metaWriteCapabilityVerdict` — the capability half of - the `/meta` write decision, beside the existing org-scope half - (`organizationIdForMetaWrite`). It admits `isSystem` and `manage_metadata` - exactly as before, and `manage_org_presentation` ONLY when the target type's - registry entry declares `allowOrgOverride: true` (registry-derived via - `declaresOrgOverride`, never a hand-written list) AND the session has an - active organization — the very organization the doors thread, so an admitted - write can only land org-scoped in the caller's own partition: never tier-B, - never env-wide, never another org's. -- **rest**: the four `/meta` item write doors (`PUT` save, `DELETE` reset, - `POST /publish`, `POST /rollback`) run the shared verdict. - `POST /meta/_migrate-stored` stays `manage_metadata`-only — an install-wide - rewrite is env-wide by definition. -- **runtime**: the dispatcher `/meta` `PUT` door runs the same shared verdict - (its `_migrate-stored` twin likewise stays `manage_metadata`-only). - -Refusals keep their transports' existing envelopes (REST `403 FORBIDDEN`, -dispatcher `403 PERMISSION_DENIED`); the tier-B refusal sentence is -byte-identical to before, and the tier-A sentences name the sanctioned path -without disclosing the caller's own grants (#7450). Platform `manage_metadata` -behaviour is unchanged on every door. diff --git a/.changeset/organization-field-pin-annotation-prose-refresh.md b/.changeset/organization-field-pin-annotation-prose-refresh.md deleted file mode 100644 index d405717e95..0000000000 --- a/.changeset/organization-field-pin-annotation-prose-refresh.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Refresh the `tenancy.organizationField` scope-pin annotation and its -`.describe()` in `packages/spec/src/data/object.zod.ts` — prose accuracy -only, no schema or behaviour change. - -The #8778 scope-pin annotation (landed by #10999) said consumer 1 (audit -stamping) was "as of this annotation, still the only one wired up" and that -consumers 2 and 3 (the approval-row writer and the automation-run recorder) -were "sanctioned but not yet implemented". #10101's PR #11311 (merged -2026-08-23) landed both: `resolveRecordOrganizationField` was promoted to a -shared resolver in `@objectstack/metadata-core` (plugin-audit re-exports it -from its original path) and all three sanctioned platform-row writers now -call it. The annotation and the `.describe()` string are updated to name all -three live consumers; the pin's load-bearing property — "The ruling sanctions -exactly THREE consumers of this key, and no others", a fourth consumer needs -its own ruling — is unchanged and still stated verbatim. - -No accept/reject behaviour change, no schema shape change, no new keys. diff --git a/.changeset/orphan-hash-shadow-column-cleanup.md b/.changeset/orphan-hash-shadow-column-cleanup.md deleted file mode 100644 index 33879bfff5..0000000000 --- a/.changeset/orphan-hash-shadow-column-cleanup.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -Retiring a shadow-carried UNIQUE index no longer leaves its generated column behind forever (#13056). - -`isHashShadowColumn`'s docblock is why the orphan-COLUMN drift pass skips a #11627 hash shadow, and it stated what happens instead: the column "is then cleaned up by the index's own removal path, not by a blind column drop". There was no such path. `dropIndexIfExists` issues one statement family — `ALTER TABLE .. DROP CONSTRAINT`, `DROP INDEX IF EXISTS`, `ALTER TABLE .. DROP INDEX` — and never touches a column. So when metadata stopped declaring the index, `diffManagedIndexes` reported it as an orphan, `os migrate apply --allow-destructive` dropped it, and the `VARBINARY(32)` STORED generated column survived keyed by nothing, while the orphan-column pass declined to report it forever, exactly as designed. A STORED generated column is recomputed and written on every INSERT and on every UPDATE touching its source columns, so a table accumulating retired declarations paid for them permanently and silently. - -The `drop_index` op now collects that column after dropping the index. Ownership is established first, never assumed — in the shape of #13015's `foreign` guard, a column the driver has not proved is its own is left in place and named in a warning rather than dropped: a column of that name that is **not generated** may hold user data, and a column some **other index still keys** is not this orphan (that second read is what makes "index first, then column" a checked precondition rather than an ordering comment). An unreadable catalog degrades to leaving the column alone. - -**Why the cleanup hangs off the op and not off `dropIndexIfExists`,** which has two other callers. The discriminator is not *which caller* but *is this index name coming back*, and only the op knows. `recreate_index` drops in order to re-create under the same name, and its shadow must survive: #13015's `reusable` branch re-keys the survivor in place instead of rebuilding the table around a regenerated STORED column, and a cleanup in the shared helper would destroy exactly that survivor on every rebuild. `replace_unique_index`'s legacy-name drop cannot reach a shadow at all — #13015 already excludes `isHashShadowCarrier` from legacy detection, in `diffManagedIndexes`, saying it does so *because* that op drops the legacy name. Both are pinned in the negative direction, since they are what a later move of the drop into the shared helper would break and nothing else would notice. - -**Why `patch` and not `minor`.** Nothing new is authorable, no export is added (the collector is `protected`), and no input that was accepted is now rejected or vice versa. What an operator will observe that they did not before is a `DROP COLUMN` in the applied set of a migration they had already opted into: the `drop_index` op was already `category: 'destructive'` and already required `--allow-destructive`, so the opt-in is unchanged — the difference is that it now finishes the job it named instead of leaving half of it on the table. A `drop_index` that finds the index already gone is now reported as *applied* rather than skipped when it collects the leftover column, because the apply did rewrite the table. diff --git a/.changeset/out-of-repo-package-names.md b/.changeset/out-of-repo-package-names.md deleted file mode 100644 index 3d65b0dd20..0000000000 --- a/.changeset/out-of-repo-package-names.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/console': patch -'@objectstack/cli': patch ---- - -Stop naming a package nobody can install: `@objectstack/framework` is not a real -package, and the multi-org remedy now says it is not publicly obtainable - -Four `@objectstack/` names appear in this repo's published docs and runtime text -without this repo building any of them. Measured against the public npm registry -(unauthenticated `GET https://registry.npmjs.org/@objectstack%2F`, with -`@objectstack/spec` and `@objectstack/cli` as positive controls so a 404 is a -fact about the name and not about access), they are **not one population but -three**: - -- `@objectstack/framework` — **404, and fabricated.** Unlike the others, nothing - in this tree describes it as enterprise, cloud, or private; it is presented as - the *default public* install. There is no umbrella package and there never was. -- `@objectstack/security-enterprise` (404) and `@objectstack/organizations` - (404) — **real, and deliberately not public.** This tree calls them - "closed-source" and "cloud-private" in a dozen places, and - `PLATFORM_CAPABILITY_PROVIDERS` declares `security-enterprise` with - `edition: 'enterprise'`. Their 404 is the caveat npm's API carries for any - private package, not evidence of fabrication. -- `@objectstack/service-tenant` — **published, at 4.1.0**, exactly as - `platform-object-names.ts` describes it. Untouched. - -What changes: - -- **`@objectstack/console`'s README** no longer opens with - `pnpm add @objectstack/framework`. The mechanism it described is real, just - misnamed: `@objectstack/cli` declares `@objectstack/console` as a dependency - and both ship at one version from the Changesets `fixed` group, so any app - that installs the CLI — every `npx create-objectstack` scaffold does — already - gets a version-matched Console. The instruction is corrected rather than - deleted, so the reader is left with something they can run. -- **`serve`'s multi-org fail-fast** kept telling an operator to add - `@objectstack/organizations` to their app without saying the runtime ships - only with an enterprise/cloud subscription. That is the un-followable "add it - to your dependencies" that framework#3366 exists to make legible. The remedy - now states it, so an operator without a licence can see that the two bullets - below it are their actual path. The declared-but-unresolvable branch is - unchanged — that operator does have the package. - -No behaviour changes: boot outcomes, exit codes and the posture wall are -untouched, and `@objectstack/security-enterprise`'s install hint is deliberately -left alone — it already names its edition boundary, and the test pinning it is -strengthened to assert that it keeps doing so. diff --git a/.changeset/outbox-ack-claim-credential.md b/.changeset/outbox-ack-claim-credential.md deleted file mode 100644 index 1bb04022ee..0000000000 --- a/.changeset/outbox-ack-claim-credential.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -**BREAKING (interface member signature):** `INotificationOutbox.ack()` now takes back the claimed record instead of a bare row id, and its compare-and-set binds the claim credential the record carries (#11859). `claim()` / `claimDigest()` declare their true return type, `ClaimedDeliveryRecord[]` — the same rows as before, with the (`claimedBy`, `claimedAt`) pair the store stamps guaranteed present — so reads of claim results do not change; the one breaking edit is at ack call sites, which hand the whole record back where they previously handed `record.id` (the caller already holds it: `ack` completes a claim, and the record is what `claim()` returned). - -Why: `status = 'in_flight'` could prove a claim exists but not whose. In the reachable sequence — node A claims a row, the send outruns `claimTtlMs`, another node's `claim()` reaps and re-claims the row, A finishes late — A's ack still matched and wrote its outcome over B's live attempt. With the credential in the predicate a late ack matches nothing, is refused with the existing `NotificationAckError` (`DELIVERY_NOT_ELIGIBLE`, ADR-0112), and writes nothing; the caller never needs to know its own `nodeId`, because ownership is proven by round-tripping what `claim()` returned. Both implementations (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) enforce it identically. - -Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). - - diff --git a/.changeset/outbox-ack-status-precondition.md b/.changeset/outbox-ack-status-precondition.md deleted file mode 100644 index 43ee0fc6f8..0000000000 --- a/.changeset/outbox-ack-status-precondition.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`INotificationOutbox.ack()` enforces its declared precondition — the row must be claimed — in both implementations, and `attempts` moves only for a real dispatch attempt - -`ack()` is the dispatcher's completion callback for a row it CLAIMED, and -neither implementation checked that. `MemoryNotificationOutbox.ack` looked the -row up by id and mutated it; `SqlNotificationOutbox.ack` read only `attempts` -by id. So `ack(id, { success: false, suppressed: true })` on an unclaimed -`pending` row succeeded, flipped the row to terminal `suppressed`, and -incremented `attempts` — which made `ack` read like the cancellation primitive -this interface deliberately does not have. - -That was a trap in two directions. It **raced the dispatcher**: between a -caller's `list()` and its `ack()`, `claim()` could take the row — `claim` is -atomic by contract and `ack` was never part of that atom — so a suppression -could land on a delivery already on the wire, or a dispatcher's real outcome -could be overwritten by a caller that thought it was cancelling. And it -**corrupted `attempts`**: the counter feeds the retry schedule -(`classifyDeliveryAttempt(result, errorClass, row.attempts, …)`), so a row -"cancelled" this way arrived at its next real attempt with the backoff already -advanced by an attempt that never went out. - -Both implementations now refuse an ack on a row that is not `in_flight`, -throwing `NotificationAckError` with this package's already-registered -ADR-0112 code `DELIVERY_NOT_ELIGIBLE` — the same refusal -`SqlHttpOutbox.redeliver` raises when its own compare-and-set misses. A refused -ack writes **nothing**: status, `attempts` and `error` are left exactly as they -were, so the row stays claimable and its backoff position stays honest. An id -matching no row remains a silent no-op — an absent row has no state to corrupt -and no claim to lose. - -`SqlNotificationOutbox` does it as an **atomic conditional update** rather than -a read-then-write, because a read cannot hold a row still and a read-then-write -is the same defect wearing a different hat. The precondition is re-stated in -the write (`where: { id, status: 'in_flight' }`), which — per #11009 — must -ride the predicate path: on the by-id path the driver binds only the primary -key and the extra predicate is silently discarded. `attempts` is incremented -inside that condition and nowhere else, so the counter can only move for a row -that was genuinely claimed. A conditional write that matches nothing is -reported rather than passed off as success. - -`NotificationDispatcher` absorbs exactly one refusal — `DELIVERY_NOT_ELIGIBLE` -— logs it and continues with the rest of the batch, because a send slower than -`claimTtlMs` legitimately loses its claim to the visibility-timeout reap, and -letting that unwind the partition loop would strand every still-valid row in -the batch `in_flight` until its own timeout expired. Any other error still -propagates. - -The sibling HTTP outbox is deliberately untouched: `assertHttpRedeliverable` -depends on `IHttpOutbox.ack` incrementing `attempts` unconditionally, so that -`attempts === 0` on a terminal row still means "parked, never sent". diff --git a/.changeset/package-door-declared-code.md b/.changeset/package-door-declared-code.md deleted file mode 100644 index 92b9bedd93..0000000000 --- a/.changeset/package-door-declared-code.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the direct-mount package door carries a demoted producer code on `declaredCode` (#12405) - -`GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` -and `DELETE /api/v1/packages/:id` now put a producer's own error-code spelling on -the wire's `error.declaredCode` when the closed ADR-0112 vocabulary did not admit -it. Previously that spelling was resolved and then dropped: `sendThrownError` -(`packages/rest/src/package-routes.ts`) asked `resolveThrownHttpError` for the -answer — which returns `declaredCode` exactly when the demote happened — and then -forwarded only `details` to the shared envelope writer. - -Nothing invalid shipped, which is what made the loss silent and one-directional: -the closed `code` still carried the member the HTTP status derives, so every body -parsed, while an author's spelling vanished and a consumer told by ADR-0112 to read -`declaredCode` found nothing there. - -This ADOPTS the rule two sibling doors already apply rather than inventing one — the -demote is read through `demotedDeclaredCode`, the single definition of "presence -means demotion", exactly as the dispatcher's `errorFromThrown` -(`packages/runtime/src/http-dispatcher.ts`, #9106) and the flat `/data` door's -`thrownCodeFields` (`packages/rest/src/error-response.ts`, #9232) do. That matters -here specifically: the runtime dispatcher domain is the TWIN transport for -`/api/v1/packages`, and it has emitted this channel all along, while this -direct-mount registrar — which registers first and is therefore the one production -serves for the three routes both declare — dropped it. One path, two doors, -disagreeing on a declared channel. - -Additive and shape-preserving. A REGISTERED producer code still carries no -`declaredCode` (repeating it would put two spellings of one fact on every refusal), -a producer that declared no code still carries none, `details` is untouched, and -`code`/`status`/`message` are byte-identical to before on every existing path. The -5xx message withhold is unchanged and does NOT suppress the demote: that withhold is -scoped to the prose by its own contract (`status`, `code` and `details` untouched), -`declaredCode` is a code channel, and the twin applies no status condition to it -either. - -⛔ `userMessage` is deliberately NOT threaded here — the shared `sendError`'s `extra` -has admitted it since #12404, and that channel is threaded separately, in #12502. diff --git a/.changeset/package-door-user-message.md b/.changeset/package-door-user-message.md deleted file mode 100644 index ef0f65c936..0000000000 --- a/.changeset/package-door-user-message.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the direct-mount package door carries a producer-marked `userMessage` (#12502) - -`GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` -and `DELETE /api/v1/packages/:id` now put a producer's user-facing refusal text on -the wire's `error.userMessage` when the throw carried one. Previously that text was -resolved and then dropped: `sendThrownError` (`packages/rest/src/package-routes.ts`) -asked `resolveThrownHttpError` for the answer — which returns `userMessage` exactly -when the producer marked a non-empty string at throw time (#9934) — and then -forwarded only `details` and `declaredCode` to the shared envelope writer. - -Nothing invalid shipped, which is what made the loss silent: `code`, `status` and -`message` were all correct, so every body parsed, while an author's deliberate, -end-user-addressed sentence vanished and a consumer told by ADR-0112 to render -`userMessage` verbatim found nothing there and fell back to its generic -substitution — the #3821 behaviour this channel exists to override. - -This completes the pair the sibling change left open. That one threaded -`declaredCode` and said `userMessage` was "tracked separately"; this is it, and the -`extra` it spreads into has admitted the field since #12404. - -⛔ The idiom is the INVERSE of the sibling's, deliberately. `declaredCode` must be -read through `demotedDeclaredCode` because its raw field carries a second meaning — -it is also set when the producer's spelling IS the registered member, so forwarding -it raw would put two spellings of one fact on every registered refusal. -`userMessage` has no second meaning: `declaredUserMessage` already decided what -counts as marked (a non-empty string, or nothing), so the caller passes -`thrown.userMessage` straight through, byte for byte what the dispatcher twin -serving this same path does (`errorFromThrown`, -`packages/runtime/src/http-dispatcher.ts`). Consumers must not read presence as -anything but "the producer opted in". - -Additive and shape-preserving. An unmarked refusal still carries no `userMessage`, -and the three shapes `declaredUserMessage` rejects — `''`, whitespace-only, a -non-string — still carry none, so nothing invents a marked message for a producer -that never wrote one. `details`, `declaredCode`, `code`, `status` and `message` are -byte-identical to before on every existing path. - -The 5xx message withhold is unchanged and does NOT suppress the mark: that withhold -rewrites a local `message` const and `looksLikeInternalErrorLeak` is only ever handed -`thrown.message`, so the marked channel is never an input to it. The two are -answering different questions — leaked diagnostic prose is withheld, while the -producer's own sentence to the end user discloses only what it chose to — and the -ruling that created the channel made it status-agnostic on purpose. - -⚠️ Stated because it is the honest cost, and so the next channel added here does not -have to rediscover which bar applies: the IN-TREE producer set at this door is empty, -for this channel and for `declaredCode` alike. This door is judged live because it is -**composed rather than closed** — `resolvePackageService()` and the `protocol` slice -are open composition points whose throws all four handlers forward verbatim, and -ADR-0112's federation amendment exists precisely because the producer set is not -enumerable in-tree. The live population is the injected/federated limb, which is the -population the new pins in `packages/rest/src/package-door-user-message.test.ts` -drive. diff --git a/.changeset/page-component-responsive-removed.md b/.changeset/page-component-responsive-removed.md deleted file mode 100644 index e25f43fa76..0000000000 --- a/.changeset/page-component-responsive-removed.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `page.components[].responsive` and the `ResponsiveConfig` layout vocabulary; repair every shipped text that prescribed it (#11027, ADR-0049 D2) - - - -**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). - -`page.components[].responsive` was the LAST carrier of the per-breakpoint -LAYOUT block (`ResponsiveConfig`: grid columns / visibility / display order on -the Tailwind `xs…2xl` axis) — and the destination the -`dashboard.widgets[].responsive` tombstone (#4876) prescribed verbatim as the -live alternative ("which objectui `useResponsiveConfig` really does read"). -Measured across objectstack + objectui with the tsc-probe methodology -(positive and negative controls; objectui `3b147a367`, objectstack -`8d21f7a76`): that claim was false. objectui's two complete, published -implementations of the contract — `useResponsiveConfig` (`@object-ui/mobile`) -and `ResponsiveProtocol` (`@object-ui/core`) — had ZERO callers, nothing in -either repo read `.responsive` off a page component, objectui's own -`BaseSchema` node interface never declared the key, and zero authored -instances exist. An author following the shipped prescription moved an inert -key to an inert key and was told it now works. Identical disposition to -`view.responsive` (#3896) and `dashboard.widgets[].responsive` (#4876) on -identical evidence. - -**What is refused:** an authored `responsive` on a page component. The key is -a `retiredKey()` tombstone, so authoring it is a `tsc` error and a parse error -carrying the prescription. - -**What leaves with it:** `ResponsiveConfigSchema` / `ResponsiveConfig`, -`BreakpointColumnMapSchema` / `BreakpointColumnMap`, -`BreakpointOrderMapSchema` / `BreakpointOrderMap`, and the `BreakpointName` -enum — no other authorable carrier existed, and an exported value schema with -no consumer reads as a capability (#3950; the `PerformanceConfigSchema` -precedent). Importing any of them is TS2305 from this release. - -**What stays:** `responsiveStyles` (ADR-0065, `ResponsiveStylesSchema` / -`StyleMapSchema`) — the per-breakpoint channel objectui really compiles to -id-scoped CSS — is untouched and is what every repaired text now points at. - -The redirect repair (the reason this ships as one change): four author-facing -surfaces shipped the false redirect and are corrected together — the #4876 -tombstone prescription (`dashboard.zod.ts`), the generated widget reference -page, the protocol upgrade guide prose, and the protocol-17 migration -rationale that `os migrate meta --from 16` prints. The `dashboard.json` -liveness note and the #4876 conversion summary carried the same claim and are -corrected too; `responsive.zod.ts`'s `hidden → hiddenOn` alias curation, which -justified itself by `useResponsiveConfig`'s return shape, leaves with the -schema that hosted it. - -The retirement kit: - -- `retiredKey()` tombstone at the schema (`packages/spec/src/ui/page.zod.ts`), - prescription pointing at `responsiveStyles` with the CSS translations for - `columns` / `hiddenOn` / `order` -- ADR-0087 registration: retired-key entry `ui/PageComponent:responsive`, four - retired-def entries (`ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, - `ui/BreakpointOrderMap`, `ui/BreakpointName`), and the D2 conversion - `page-component-responsive-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips the key from authored pages at - every component position, region, slot, or nested container (pure lossless - delete; it never had an effect to lose) -- pin tests (`page.test.ts` — refusal pin asserts the prescription; a positive - pin parses a component without the key and asserts `responsiveStyles` - survives; `dashboard.test.ts`'s #4876 pins now assert the corrected - prescription instead of the false redirect) -- `ResponsiveStylesSchema` guidance for `columns` / `hiddenOn` / `order` now - names the retirement and the CSS that IS applied, instead of prescribing the - dead sibling key -- generated baselines/docs follow the schema (authorable surface, JSON-schema - manifest, api-surface, export-origins, spec-changes, upgrade guide, - reference docs, skill references) -- objectui's two dead consumer implementations are the other half of this - measurement and are queued under objectui#4773 (this package's texts no - longer point authors at them) - -## FROM → TO - -```ts -// before — parsed green; no renderer ever applied any of it -{ - type: 'element:text', - id: 'kpi_label', - responsive: { - columns: { xs: 12, lg: 4 }, - order: { xs: 2, lg: 1 }, - hiddenOn: ['xs'], - }, -} - -// after — express per-breakpoint behaviour as scoped CSS (ADR-0065), which -// objectui compiles and applies (desktop-first buckets) -{ - type: 'element:text', - id: 'kpi_label', - responsiveStyles: { - large: { gridColumn: 'span 4', order: '1' }, - small: { gridColumn: 'span 12', order: '2' }, - xsmall: { display: 'none' }, - }, -} -``` diff --git a/.changeset/page-component-type-vocabulary-gate.md b/.changeset/page-component-type-vocabulary-gate.md deleted file mode 100644 index c8bbfd03e3..0000000000 --- a/.changeset/page-component-type-vocabulary-gate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap. - -`PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, and the open string arm is deliberate: custom and registered components (`object-grid`, `mcp:connect-agent`, `custom.widget`, kebab SDUI blocks) keep parsing exactly as before — nothing about the parse changed. What is new is that the spec now answers for its own namespaces (`page:` `record:` `nav:` `global:` `user:` `ai:` `app:` `element:`, derived from the enum): a type inside them that the vocabulary does not declare is refused at author time by the new gating rule `component-type-unknown` (`os validate` / `os build` / `os lint`), with the closest declared spellings suggested. Previously `global:serch` validated clean and the published page drew a literal "Component Placeholder" scaffold in front of the end user. - -- `@objectstack/spec` exports the vocabulary claim from `@objectstack/spec/ui`: `RESERVED_COMPONENT_TYPE_NAMESPACES` (derived), `KNOWN_COMPONENT_TYPES` / `KNOWN_COMPONENT_TYPE_CANDIDATES`, `STRING_ARM_REGISTERED_TYPES` (the evidenced ledger of registered-but-row-less types, currently `record:line_items`), and the `hasReservedComponentNamespace` / `isKnownComponentType` predicates. -- `@objectstack/lint` ships `validateComponentTypes` (rule id `component-type-unknown`, severity `error`) on all three CLI commands; the runtime publish door is deliberately deferred pending a measured false-refusal budget over stored tenant page rows. - -If a page authored a type in a reserved namespace that nothing declares, the fix is the rule's own hint: rename to the suggested declared type, or move a genuinely custom component to its own namespace (e.g. `my-plugin:widget`) so it cannot be mistaken for platform vocabulary. diff --git a/.changeset/page-declaration-shape-gate.md b/.changeset/page-declaration-shape-gate.md deleted file mode 100644 index c6d24200f2..0000000000 --- a/.changeset/page-declaration-shape-gate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -`CONNECT_AGENT_PAGE` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery can see it. It reaches the kernel through `CONNECT_AGENT_UI_BUNDLE.pages`, but was authored as a bare `export const CONNECT_AGENT_PAGE = { … }` with per-field `as const` — the same shape `MarketplaceInstalledPage` shipped in before #11574, and invisible to the export-shape scan the canonical-envelope gates (#11255, #11480) discover their population with. A new repo-wide gate, `check:page-declaration-shape`, now closes the class: every identifier in a bundle's `pages:` array must be declared `export const X: Page =` or through `definePage()` (#11576). diff --git a/.changeset/permission-metadata-door-lock.md b/.changeset/permission-metadata-door-lock.md deleted file mode 100644 index cc9e983ebd..0000000000 --- a/.changeset/permission-metadata-door-lock.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -The packaged-permission-set lock now guards the metadata door as well as the data door. The pre-persistence authoring-gate seam gains a `permission` registration (`registerPackagedPermissionSetLockGate`, new export) that consults the same `classifyPackagedPermissionSet` classifier and throws the same `PackagedPermissionSetLockedError` the `sys_permission_set` write door already uses — one spelling of "package-declared", two doors, one refusal. - -What stops working, and for whom: an operator using the `OS_METADATA_WRITABLE=permission` escape hatch to save a permission set that an installed package declares now gets `403 NOT_OVERRIDABLE` (the refusal names the sanctioned clone path) instead of silently minting a `sys_metadata` overlay whose grants win at read over the package's declaration. This applies hatch open or closed, draft and publish saves alike, and also means stored-overlay maintenance passes (e.g. stored-item migration) report a per-row refusal for such grandfathered forks rather than rewriting them. - -What keeps working unchanged: hatch writes to any name no installed package declares — the hatch's documented per-org/env override capability — land exactly as before, and package-door authoring of workspace-owned sets (definitions living in `sys_metadata`; ADR-0070, ADR-0094 D5-R) stays editable. Package publishes travel the `package-author` channel, which this seam exempts by contract. diff --git a/.changeset/permission-restore-purge-retired.md b/.changeset/permission-restore-purge-retired.md deleted file mode 100644 index 179f483e84..0000000000 --- a/.changeset/permission-restore-purge-retired.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-security": patch ---- - -feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, 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). -Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's -recommendation B; **the keys return with the M2 lifecycle initiative** (feature -+ RBAC in one batch) — anchor card #1883 stays open. - -`allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and -`purge` (hard-delete / GDPR erase) ObjectQL operations that have never -existed: no destructive lifecycle verb is in the engine's dispatch vocabulary -(pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). -Authoring the bits granted nothing — and in the `allowPurge: false` direction -the failure was ADR-0049's worst false-compliance shape: an admin believed a -lock on permanent deletion existed when the operation itself did not. The -sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` -door) and is untouched. - -**What is refused:** authoring either key, with any value — both are -`retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the -`permission` metadata root, so the tombstone route keeps the removal audible: -a tsc `never` on the input type plus a parse-time prescription). The former -`restore` / `purge` bare-verb aliases now answer with the same prescription -instead of a rename onto a tombstone. The tombstone rides the `.extend()` -clone into `EffectiveObjectPermissionSchema`, so the response-side def carries -the same `[RETIRED]` rows. - -**What stays accepted:** every other object-permission bit parses -byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, -`viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). - -**Runtime (plugin-security):** the evaluator's pre-mapping rows -(`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in -the same batch — with the bits unwritable, a mapping onto them was a claim -about a surface that rejects authoring. Behaviour is deny-before and -deny-after: a dispatched `restore` / `purge` is refused fail-closed by the -`DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even -`modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers -them only when the M2 batch re-adds the rows). `transfer` keeps its row and -its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy -stored value grants nothing, so flagging it guarded nothing real); the -delete/purge/transfer class message is unchanged. - -The retirement kit: - -- `retiredKey()` tombstones + former-alias `guidance` prescriptions at the - schema (`packages/spec/src/security/permission.zod.ts`) -- ADR-0087 registration: retired-key entries - `security/ObjectPermission:allowRestore` / `:allowPurge` (and the - `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 - conversion `permission-allow-restore-purge-removed` (protocol 18), wired - into the step-18 chain — `os migrate meta --from 17` strips the keys from - every object grant in `permissions[].objects` (pure lossless delete; they - never had an effect to lose) -- liveness ledger: both entries flipped to `dead` with the retiredKey evidence - (entries stay — the tombstone keeps the keys in the walked shape, the - `rls.priority` precedent) -- pin tests (`permission.test.ts` — refusal pins asserting the prescription; - `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant - and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate - no longer reads the retired bit) -- generated baselines/docs follow the schema (`authorable-surface/`, - `authorable-defaults/`, spec-changes, upgrade guide, reference docs) - -## FROM → TO - -```ts -// before — parsed green; nothing ever read the bits, no operation existed -definePermissionSet({ - name: 'support_agent', - objects: { - crm_ticket: { - allowRead: true, allowEdit: true, - allowRestore: true, // claimed: can undelete — nothing enforced it - allowPurge: false, // claimed: GDPR erase locked — no lock existed - }, - }, -}); - -// after — delete the keys; restore/purge dispatches are denied fail-closed -// until the M2 lifecycle batch ships the operations WITH their RBAC bits -definePermissionSet({ - name: 'support_agent', - objects: { - crm_ticket: { allowRead: true, allowEdit: true }, - }, -}); -``` - - diff --git a/.changeset/permission-set-refusal-visibility.md b/.changeset/permission-set-refusal-visibility.md deleted file mode 100644 index 4e8e8206d9..0000000000 --- a/.changeset/permission-set-refusal-visibility.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(plugin-security): report the two swallowed `tryUpdate` refusals outside the catalog seed (#12970) - -Both sites call the shared `tryUpdate` in `permission-set-projection.ts`, which -answers `false` on refusal. That answer is byte-identical to "nothing to do", -and neither caller passed the optional refusal log the helper already accepts — -so a refused write was indistinguishable from a clean pass. - -**`permission-set-drift.ts` — a refused diagnostic write silenced its own -report.** `persistPermissionSetDriftDiagnostics` counted only the writes that -landed, and `runPermissionSetDriftDiagnostics` reported only when that count was -non-zero. A boot on which every drift write was refused computed the drift -correctly, persisted none of it, and printed nothing at all — indistinguishable -from a deployment with no drift, while the sets kept enforcing grants that -differ from the shipped artifact. The pass now records refusals, answers a -`refused` count beside `updated`, reports them once per pass on the durability -channel, and emits the drifted-set line when writes were refused as well as when -they landed. A steady-state boot (nothing to write, nothing refused) stays -exactly as quiet as before. - -**`permission-set-overlay-discard.ts` — the audit line could describe a discard -that did not happen.** On the degraded-kernel branch the resync write's result -was discarded entirely. On refusal the row was re-read unchanged, so -`objectGrantsAfter` equalled `objectGrantsBefore` while the `info` entry still -announced a completed "sanctioned operator action": every field individually -true, the entry as a whole false. The result is now read, and a refused resync -emits one entry stating what did and did not land — the overlay row deletion -(which had already succeeded) and the refused resync, with the un-healed grant -count named as such — **instead of** the success line, never alongside it. - -Both new lines go through the shared durability channel with its mandatory -`warn` fallback, so they still print against a host sink that has no `error`. -They reuse the shared refusal *accumulator* (`createSeedWriteRefusals`, with its -cross-dialect classification and value-free driver-code channel) but not -`reportSeedWriteRefusals`, whose prose is specific to seeding the RBAC catalog -and would misdiagnose either of these paths. - -No API is removed or narrowed. `persistPermissionSetDriftDiagnostics` and -`runPermissionSetDriftDiagnostics` answer one additional field (`refused`), and -what `discardPermissionSetOverlay` returns to its caller is deliberately -unchanged. diff --git a/.changeset/permission-set-residue-fallback.md b/.changeset/permission-set-residue-fallback.md deleted file mode 100644 index c493669844..0000000000 --- a/.changeset/permission-set-residue-fallback.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -An organization-less `sys_permission_set` row grants again — #11121 revoked standing access silently - -#11121 made the request-time permission-set loader tenant-scoped so two -organizations holding a row for the same name stop answering each other's -requests. It shipped the second half as a COMMENT — "an organization-less -leftover only where it does not [have its own]" — and the code read `.own` -alone, which by `resolveOwnOrganizationRow`'s own documented contract is never -a residue once an organization is supplied. - -That helper is written for SEEDERS, where refusing to read a residue as -"already seeded" is the entire point. Enforcement wants the opposite reading: an -organization-less row is still a row the principal was granted, and dropping it -revokes standing access with no signal at the moment of loss — the failure this -catalog's own header, and `resolve-authz-context`'s `sys_position` read, both -name as the thing not to do. - -The asymmetry was observable on a single row: its `system_permissions` and -`tab_permissions` kept applying, because that read is unscoped and by id, while -its `object_permissions` and `admin_scope` stopped. One row, two enforcement -planes, opposite verdicts. Every walled deployment carrying pre-#11121 rows — -or any row authored without a tenant, which includes admin-UI-authored sets — -lost those grants on upgrade, reported only as a boot WARN about "leftovers" -that states the catalog is complete. - -Found by cloud's `apps/ee-group-showcase` dogfood suites, which had been failing -four ADR-0111 / ADR-0105 assertions on cloud main while turbo replayed them from -cache. - -Preference order is unchanged, so the cross-tenant bleed #11121 closed stays -closed: this organization's own row still WINS wherever it exists, and a -leftover is consulted only in its absence. #11121's suite covers seeding and the -`sys_position` sweep; the three cases added here cover the loader path it did -not — residue resolves, own beats residue, and the single-posture carve-out is -untouched. Reverting the one-line fix reddens exactly the first of them. diff --git a/.changeset/perrow-sandbox-signal.md b/.changeset/perrow-sandbox-signal.md deleted file mode 100644 index 9404a42453..0000000000 --- a/.changeset/perrow-sandbox-signal.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility. diff --git a/.changeset/pg-date-calendar-day-not-local-midnight.md b/.changeset/pg-date-calendar-day-not-local-midnight.md deleted file mode 100644 index 48817588ab..0000000000 --- a/.changeset/pg-date-calendar-day-not-local-midnight.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -Stop reading every PostgreSQL `Field.date` one day early on a process east of UTC - -On PostgreSQL a `Field.date` came back **one calendar day early** whenever the -Node process ran east of UTC — an app container on `TZ=Asia/Shanghai` served -`"apply_date": "2026-08-23"` for a row `psql` reads as `2026-08-24`. The stored -value was always right; the read corrupted it, so the wrong day was already in -the REST payload before anything rendered it. Worse than a display bug: an -`afterUpdate` hook copying a date into a child record persisted the shifted -value, writing the wrong day back into the database. - -`node-postgres` materialises OID 1082 (`date`) as a JS `Date` at **local** -midnight, and `SqlDriver#toDateOnly` reads a `Date` with **UTC** components. -East of UTC, local midnight is the previous day in UTC. Measured on PostgreSQL -16, one stored row `2026-08-24`, only the process `TZ` changed: - -| process `TZ` | `pg` materialised | driver returned | -|---|---|---| -| `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | -| `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | -| `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | - -Fixed at the parser rather than the reader: the driver now registers a -connection-scoped type parser so `date` (OID 1082) and `date[]` (1182) arrive -as their `YYYY-MM-DD` wire text and never become a `Date` at all — the same -shape SQLite has always had, and the same shape MySQL already had via the -existing UTC connection pin. `timestamptz` is untouched: an instant is what a -`Date` is for, and `Field.datetime` depends on it. The parser is registered on -the connections this driver opens, never through the process-wide -`pg.types.setTypeParser`, so a host application's own `pg` clients keep stock -behaviour. - -Reading local components in `toDateOnly` instead was measured and rejected: -that helper is shared by the read, write and filter paths, and a caller's -`new Date('2026-08-24')` is UTC midnight — local components would report it as -`2026-08-23` west of UTC, i.e. the identical one-day error moved onto the write -and filter paths. `toDateOnly` now documents the UTC clock as its contract. - -**If you worked around this, you can undo the workaround.** Running the app -process with `TZ=UTC` is no longer a prerequisite for correct dates, and any -app-side "+1 day" compensation on a PostgreSQL date read must be removed — with -this release the driver returns the stored day, so a compensating shift now -overshoots. Rows that were *written* through the old skew (a hook that copied a -date it had just read) still hold the wrong day and need a data fix; nothing -here rewrites stored data. - -One behaviour change beyond the corrected day: on PostgreSQL a raw read -(`driver.execute(...)`, or knex used directly on this driver's connection) now -yields a `string` for a `date` column where it previously yielded a `Date`. -Values leaving `find()` / `findOne()` / `aggregate()` / `distinct()` were -already normalised to `YYYY-MM-DD` strings and keep that type — only the day -they name changes. - -Pinned by a process-zone matrix (`UTC`, `Asia/Shanghai`, `America/New_York`, -`Asia/Kolkata`) that asserts it contains an east-of-UTC cell before it believes -itself: the existing live-Postgres CI job runs at `TZ=America/New_York`, which -is west of UTC, where the pre-fix read names the right day — which is why this -was green in CI for as long as it was broken in production. diff --git a/.changeset/pg-introspect-fk-join-correlations.md b/.changeset/pg-introspect-fk-join-correlations.md deleted file mode 100644 index 3ea0deaaaa..0000000000 --- a/.changeset/pg-introspect-fk-join-correlations.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Fix:** `introspectForeignKeys`' Postgres arm no longer drops a cross-schema foreign key, nor returns a composite one as a cartesian product (#11324). - -The arm joined three `information_schema` views, and the correlations were wrong in two independent ways. Both were measured on live PostgreSQL 16.13, against the query as it stood after #11201, so neither was caused by nor repaired by that change. - -**A foreign key whose target lived in another schema vanished.** The join carried `ccu.table_schema = tc.table_schema`, which demands parent and child sit in the same schema. For a FOREIGN KEY constraint, `constraint_column_usage` describes the *referenced* side — that is exactly why the projection aliases it `referenced_table` — so its `table_schema` is the **parent's**, not the constraint's. A cross-schema reference therefore contributed **zero rows**, and the table reported having no foreign keys at all. That is the #7332 failure mode through a different door and it has no `onFailure` to consult, because nothing failed: `[]` does not read downstream as "I could not see it", it reads as *this table has no foreign keys*, and federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift comparison all act on it. Cross-schema references are the normal shape for the federated remotes ADR-0015 points this driver at. - -**A composite foreign key came back as the cartesian product of its columns.** The `kcu` ↔ `ccu` join carried no ordinal correlation at all, so an N-column key yielded N x N rows pairing every child column with every parent column. Measured, a 2-column key `(x, y) references p (a, b)` returned **four** records — `x -> a`, `x -> b`, `y -> a`, `y -> b` — where the answer is `x -> a`, `y -> b`. Because `IntrospectedForeignKey` is a flat per-column record, the two phantom pairs are indistinguishable from the real ones to every consumer: a wrong-shaped answer that type-checks. - -**The whole query moves to `pg_constraint` rather than the join predicate being patched.** `constraint_column_usage` exposes no ordinal column at all — measured, its seven columns are the catalog/schema/name triples for the table and the constraint plus `column_name` — so the composite half has nothing to correlate on inside `information_schema`. The conservative half-fix was tried and measured: correlating `ccu` on `tc.constraint_schema`, the spelling `introspectUniqueConstraints` already carries, repairs the cross-schema case and leaves the composite case at four rows. `pg_constraint` carries both facts on one row — `conkey` and `confkey` are parallel `smallint[]`s in key order — so unnesting them *together* pairs child column with parent column by construction, and `unnest(...) WITH ORDINALITY` keeps the key position the old join threw away. That is the shape `introspectPrimaryKeys` already uses for `indkey` (#11101 / #11162), and dropping to the catalog matches what that arm and `introspectIndexes` already do. - -**No interface change.** `IntrospectedForeignKey` keeps its flat per-column shape and gains no ordinal field. A composite key is expressed as **ordered sibling rows** — contiguous, in declared key order, each pairing its own child column with its own parent column — which `ORDER BY con.conname, con.oid, k.ord` now pins and the type's docblock now states. Measured on a key declared out of column sequence, `foreign key (second_col, first_col)`, the result is key order rather than column order. An ordinal field was considered and rejected: it would let a wrong `ORDER BY` keep shipping wrong rows that merely *describe* their wrongness, where the pairing is a fact the query itself has to get right. - -Schema scoping is unchanged in meaning: `ns.nspname = ANY (current_schemas(false))` is #11201's `tc.table_schema = ANY (…)` expressed over the catalog, so a same-named table in a schema `search_path` never reaches still contributes nothing. An unknown table name still yields an empty list rather than a throw, so the #7332 `onFailure` contract is untouched. diff --git a/.changeset/plain-pandas-battle.md b/.changeset/plain-pandas-battle.md deleted file mode 100644 index 00792affe1..0000000000 --- a/.changeset/plain-pandas-battle.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/cli": patch ---- - -`os migrate plan` / `apply` examine the object set the composed host DECLARED, and report the boundary when they cannot - -A composed host stack (#12938) registers its plugins for their DECLARATIONS: `init()` runs, `start()` is suppressed. The pass that hands every registered object to its driver — the one that fills the `managedObjectFields` map `detectManagedDrift()` diffs — lives in `ObjectQLPlugin.start()`, and a host that brings its own `ObjectQLPlugin` (under the framework's own plugin name, so the CLI's capability injector de-dups against it) DISPLACES the standalone one, since duplicate registration overwrites by name. The result was a boot where no `ObjectQLPlugin.start()` ran at all: every host plugin declared its objects, and not one reached a driver. - -Measured on ObjectStack Cloud's staging control plane: 36 host plugins composed, ~80 `sys_*` tables declared, **8** examined — all eight belonging to the single service that provisions its own tables from a `kernel:ready` hook rather than relying on that pass. Every consumer-visible signal was green, and `Physical schema is in sync with metadata` was one composed plugin away from printing over seventy unexamined tables. - -Two changes: - -- **The composed boot now drives that pass itself**, over the deferral it already armed: `engine.syncObjectSchema(name)` per declared object, which reaches `SqlDriver.initObjects` exactly as the suppressed `start()` would have. A plan still writes nothing — the deferral records the create-table work instead of running it. -- **`plan` / `apply` report what they could NOT examine.** `--json` payloads gain `composition.coverage` (`registeredObjects`, `examinedObjects`, `unexaminedObjects`, and per-reason counts: federated, unbound, on another datasource, on a driver without schema registration, refused). When `unexaminedObjects > 0`, the human output refuses the unqualified "in sync" line and says the plan is PARTIAL instead. A consumer gate asserting coverage should read `composition.coverage.unexaminedObjects` — `managedTables` alone cannot tell a small deployment apart from a mostly unexamined one. - -`@objectstack/driver-sql`: `initObjects` no longer calls `ensureDatabaseExists()` while DDL is deferred. It is the one line there that can write — `mkdir -p` for a sqlite parent directory, and on Postgres/MySQL a `SELECT 1` that CREATEs the database on `3D000` / `ER_BAD_DB_ERROR` — and under the deferral there is no DDL for a database to exist for. `flushDeferredSchemaDdl` clears the flag before re-entering, so the confirmed `os migrate apply` still ensures the database ahead of the first `CREATE TABLE`. - -A project with neither an `objectstack.config.*` nor a compiled artifact is unchanged: it composes nothing, carries no `composition` key, and diffs the same five data-stack tables it always did. diff --git a/.changeset/planned-liveness-verdict-not-dead.md b/.changeset/planned-liveness-verdict-not-dead.md deleted file mode 100644 index 67bd2ec761..0000000000 --- a/.changeset/planned-liveness-verdict-not-dead.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`lintLivenessProperties` no longer tells authors a `planned` property is `dead` - -`describe()` in `lint-liveness-properties.ts` only knew two verdicts -(`experimental`, everything else → `dead`), while the liveness ledger ships a -third: `status: 'planned'` (declared, and a consumer is being built against -it — contract-first, the opposite of `dead`). Every `planned` row fell through -into the `dead` branch, so the finding's own **message** told the author to -remove metadata the platform had asked them to write, while the same finding's -**hint** (when the row carried one) said the opposite one sentence later. Three -shipped rows hit this: `field.relatedListFilter`, `object.externalSharingModel`, -`translation.flows`. - -`describe()` now has a third branch: `status === 'planned'` gets its own rule -id (`liveness-planned-property`, mirroring `liveness-dead-property` / -`liveness-experimental-property`'s advisory-only posture — nothing downstream -keys off these ids today) and its own message/default hint ("keep it — a -consumer is being built against this property", never "Remove it"). - -The ledger's `status` field is a documented vocabulary, not a Zod-enforced -enum — nothing rejects a ledger entry with an unrecognised status. `describe()` -previously graded any such entry `dead` silently; it now throws, naming the -offending status, so a ledger-authoring mistake (a typo, or a new status added -without teaching this file about it) fails loudly at test time instead of -mislabelling a finding. diff --git a/.changeset/platform-admin-capability-export.md b/.changeset/platform-admin-capability-export.md deleted file mode 100644 index 64d9b0c90c..0000000000 --- a/.changeset/platform-admin-capability-export.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/plugin-security': patch ---- - -Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test. diff --git a/.changeset/platform-admin-config-anchor.md b/.changeset/platform-admin-config-anchor.md deleted file mode 100644 index 846cfb9acd..0000000000 --- a/.changeset/platform-admin-config-anchor.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/plugin-auth': minor ---- - -`PLATFORM_ADMIN` can now be anchored on deployment CONFIGURATION instead of a stored grant row: an account whose `sys_user.email` is on `OS_PLATFORM_OWNER_EMAIL` **and** whose `email_verified` reads verified resolves `PLATFORM_ADMIN` with the declared `admin_full_access` capability set, derived live on each authorization resolution (#11663 leg L2, design accepted 2026-08-25 as bundle 1A/2B/3A/4A/5A/6A/7A). - -**Additive — nothing is revoked.** The legacy unscoped `admin_full_access` grant still confers exactly as it did; a holder whose standing rests on the row alone now gets a once-per-process pointer at the configuration line that re-anchors them. A deployment that has declared no administrators resolves byte-identically to before: the config list is empty, the derivation answers "not an admin" before it reads any row, and the pinned batch-equivalence query multiset is unchanged. - -**The variable takes a list.** `OS_PLATFORM_OWNER_EMAIL` accepts one address or a comma-separated list of them — one normalization (`trim().toLowerCase()`), duplicates collapsed, blank entries dropped. ⛔ Any entry that is not an address **fails the whole variable closed** with a loud refusal naming it, rather than being skipped: silently dropping a typo would leave a narrower administrator set than the operator declared, with nothing anywhere to notice. Unset, blank or refused all mean **zero** config-derived administrators. - -**Verified-email match only.** An unverified account holding a configured address confers nothing, and an ABSENT `email_verified` column reads unverified. The match reads the caller's own **stored** `sys_user` row, never the caller-supplied session email. - -New exports from `@objectstack/core`: `resolvePlatformAdminEmails`, `parsePlatformAdminEmails`, `matchesConfiguredPlatformAdmin`, `normalizePlatformAdminEmail`, `PLATFORM_ADMIN_EMAIL_SEPARATOR`, `ADMIN_STANDING_NON_TABLE_INPUTS` and the test hooks beside them. `@objectstack/core` now depends on `@objectstack/types` (measured acyclic: `types` depends only on `spec`). - -`@objectstack/plugin-auth`'s break-glass guard follows the derivation, as it must: `ADMIN_STANDING_SURFACE.sys_user` is reclassified `derives`, the last-administrator enumeration counts config-derived administrators through the resolver's own predicate, and a fifth write shape is judged — a change of address or an `email_verified` reset that would leave the environment with no administrator is refused, naming the configuration as the remedy. An ordinary profile write still costs the guard no reads. diff --git a/.changeset/platform-owner-wall-bypass.md b/.changeset/platform-owner-wall-bypass.md deleted file mode 100644 index 7ce81d4eb0..0000000000 --- a/.changeset/platform-owner-wall-bypass.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(plugin-security): the verified platform OWNER bypasses the Layer 0 org wall (#12974) - -Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能简单点,对于超级管理员, -配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」 - -When plugin-security arms the Layer 0 organization wall on a READ, the -`org_id` tenant filter is no longer appended for a session whose account is -the **verified platform owner** — the `OS_PLATFORM_OWNER_EMAIL` identity, -matched under the existing #11343 verified-email predicate (the SAME -comparison the platform-admin elevation gate makes, now shared through -`platform-owner-wall-bypass.ts`; server-side `sys_user` row facts only, never -a client-supplied claim). This unblocks the one account meant to be -all-seeing: metadata-driven operator screens over PUBLIC tenant objects no -longer read EMPTY for the deployment's declared owner (the cloud#1676 shape). - -The door is READ-only. WRITES keep today's behaviour for everyone INCLUDING -the owner: an org-less tenant-scoped write is still refused 403 naming the -missing active organization (ADR-0123 D2 — an org-less write would mint -exactly the NULL-organization rows the platform is eliminating), and the -by-id write pre-image read stays walled with it. - -Fail-closed in every direction, pinned: env unset ⇒ nobody bypasses (the wall -arms exactly as before, with no row I/O); email mismatch ⇒ walled; email -matches but the account is NOT verified ⇒ walled; only a verified match lifts -the read filter. The bypass lifts ONLY Layer 0 — object/field permissions, -business RLS (Layer 1) and the write `check` path are untouched — and the -door serves the single env-declared owner (no lists, no patterns). Every -wall-bypassing read computation emits a structured warn-level audit event -with the stable name `platform_owner_wall_bypass` (object, operation, userId, -suppressed filter). diff --git a/.changeset/plugin-auth-cookiecache-cost-disclosure.md b/.changeset/plugin-auth-cookiecache-cost-disclosure.md deleted file mode 100644 index d09dc1b97b..0000000000 --- a/.changeset/plugin-auth-cookiecache-cost-disclosure.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -docs(plugin-auth): state what the `session.cookieCache` door costs, measured against better-auth 1.7.1 (#12547) - -No behaviour change, no API change, and ⛔ **no boot refusal** — the #4785 -posture stands on both doors: deliberate opt-in with the cost disclosed -(maintainer ruling 2026-08-27). `cacheSecondaryStorage()` stays exported and -`AuthManagerOptions` accepts exactly what it accepted before. - -This is shipped as a changeset rather than left in source comments because the -CHANGELOG is where the sibling door's posture already lives — *"remains -exported for anyone who wants better-auth's cached session store deliberately. -It now says plainly what it costs."* An operator weighing `cookieCache` reads -the changelog, not our TSDoc, and one door's cost being on the public record -while its sibling's is not is the asymmetry this closes. - -**What was measured**, against the installed better-auth `1.7.1` (read out of -`node_modules`, never off the `^1.7.1` range): - -- **The failure direction is the sibling's.** With `cookieCache` enabled, - `/get-session` answers from a signed payload in the client's own - `session_data` cookie and returns before any adapter read. ObjectStack - revokes by writing the `sys_session` row (ADR-0069 D4) and hides tombstoned - rows from better-auth's reads — all read-path enforcement, so while the - cookie answers, a revoked session keeps authenticating and nobody gets an - error. -- ⭐ **The reach is materially smaller, and the disclosure says so rather than - inheriting the sibling's wording.** The session of record does not move — - `createSession` still writes the row, so admin session lists, the - concurrent-cap count and D4's audit trail stay correct. The staleness window - is bounded and per-client (`cookieCache.maxAge`, default 300s) and cannot be - extended without a database read, because better-auth force-disables its - stateless `refreshCache` whenever a `database` is configured — which - ObjectStack always does. Sensitive operations already bypass it via - better-auth's own authoritative re-read. `secondaryStorage` has none of these - three bounds. -- **It is not reachable from ObjectStack config today, by construction rather - than by refusal.** The spec's `AuthConfigSchema.session` declares - `expiresIn` / `updateAge` only and `createAuthInstance` reads only those two, - so a `cookieCache` key is dropped rather than honoured. The one way in is the - `authInstance` escape hatch, where the host has replaced the whole config. - -The disclosure lands at `auth-manager.ts`'s `session:` block — the place a -future author would plumb the key — with a pointer from `secondary-storage.ts` -so the two doors are described together. An **observation** pin in -`session-of-record.test.ts` records the drop end-of-chain: a revoked session -still de-authenticates on the very next request. ⛔ It pins no refusal; it is -the tripwire a paragraph alone could not give, so the day someone plumbs -`cookieCache` through, a red test points at the cost note instead of D4 -quietly acquiring a revocation window nobody chose. diff --git a/.changeset/plugin-auth-logger-receiver-detach.md b/.changeset/plugin-auth-logger-receiver-detach.md deleted file mode 100644 index 5a6409f743..0000000000 --- a/.changeset/plugin-auth-logger-receiver-detach.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): keep the logger's receiver when selecting a log channel — class-based host loggers no longer crash, so audience refusals report their verdict instead of `500 null` (#12773) - -Three sites picked a log channel by **extracting** the method before calling it: - -- `auth-manager.ts` — `(logger?.error ?? logger?.warn)?.(message, meta)` in `audienceLogError` -- `reconcile-membership.ts` — `const log = deps.logger?.error ?? deps.logger?.warn` in `refuseInvalidPolicy` -- `adopt-membership.ts` — `const log = options.logger?.info ?? …` in `adoptExistingMembership` - -`a.b` in *call position* passes `a` as the receiver; `(a.b ?? c.d)(…)` evaluates to -the bare function first, so the call runs with `this === undefined`. A plain-closure -logger does not read `this` and survives it. `@objectstack/core`'s `ObjectLogger` is a -real class with prototype methods and no constructor binding — `error`/`fatal` reach for -`this.writeErrorLike`, `debug`/`info`/`warn` for `this.write` — so it threw: - -``` -TypeError: Cannot read properties of undefined (reading 'writeErrorLike') - at error (.../packages/core/dist/index.js:650:10) - at _AuthManager.audienceLogError (.../plugin-auth/dist/index.mjs:5460:38) -``` - -In `validateAudienceAdmission` the damage compounded: the throw from the `try` landed in -the `catch`, which called the same helper again, so the second throw escaped the gate. -An audience refusal — a decided, fail-closed 4xx naming exactly what the operator had -misconfigured — was delivered to the client as **`HTTP 500` with a null body**, and the -verdict reached neither the caller nor the log. - -Each site now calls through the **property** while keeping its fallback, so both halves -of the contract hold: the `error` → `warn` degradation (#9754) and the receiver. - -No behaviour changes for a host whose logger is a plain closure object — that shape -worked before and is pinned unchanged. The gate's decisions, codes and messages are -untouched; only their *delivery* is repaired. - -The regression pin (`logger-receiver-detach.test.ts`) uses a **class-based** logger -double whose methods dispatch through `this`, because a closure double passes against -the broken code and pins nothing; its case ⓪ asserts that receiver-sensitivity directly -so the file cannot quietly become vacuous. diff --git a/.changeset/plugin-auto-restart-never-reinitialised.md b/.changeset/plugin-auto-restart-never-reinitialised.md deleted file mode 100644 index 9b9dbe6125..0000000000 --- a/.changeset/plugin-auto-restart-never-reinitialised.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescriptions are registered -under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 -semantic entry `plugin-auto-restart-never-reinitialised` — where -`os migrate meta` users will look). Graded `minor` rather than `major` for the -same reason #12340 and #12428 were, the day before, in this same module. - -## What was measured - -`PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped -there. The comment above the call read *"Call destroy and init to restart"*, -and `init` appeared in `health-monitor.ts` **only inside that comment**. So a -plugin whose health checks crossed `failureThreshold` with `autoRestart: true` -got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`, -and periodic health checks that carried on running against the destroyed -instance. The default check when no `checkMethod` resolves is -`{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes -indefinitely — so the **terminal** report on a torn-down, never-re-initialised -plugin was `healthy`. - -Reproduced at `ee3595cefd` before anything was changed, with -`successThreshold: 3`: - -``` -round 1 (failing): status=failed destroyed=0 alive=true -after backoff: status=recovering destroyed=1 alive=false -recovery round 1: status=recovering destroyed=1 alive=false -recovery round 2: status=recovering destroyed=1 alive=false -recovery round 3: status=healthy destroyed=1 alive=false -``` - -#11955 made that report *more* convincing rather than less: reaching `healthy` -now costs `successThreshold` consecutive passing rounds, so a destroyed plugin -has to earn a declared number of passes before it is misreported. -`restartAttempts` was incremented as though a restart had occurred, and -`maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin -that was never brought back up. - -## Why REMOVE and not the other two ADR-0049 states - -**ENFORCE** would have to build the restart, and the class cannot host one. -`Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call -sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`, -`kernel.ts:607`), both over the full plugin list, with a context that is -`private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain -one, so a host-provided re-init hook would have had nothing to call. (Positive -control for that scan: the same pass resolves five real non-test -`plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a -per-plugin re-init API for a caller that does not exist — no runtime constructs -`PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision -names as the wrong default at this milestone, where the shippable liability is -the false promise and not the missing feature. - -**EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR -corpus returned **zero** mentions of plugin auto-restart, against 118 control -hits for "health" and 13 for "hot reload" in the same corpus. - -`maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than -as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and -*"Backoff strategy for restart delays"* have nothing left to be the vocabulary -**of** — the test that took `distributedConfig` out with the `stateStrategy` -value it was documented as requiring (#12340). - -## What changes for a host - -All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not -`.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a -milder form of the defect being retired. A TypeScript host gets a `tsc` error -(the keys are typed `never`); a parse raises the prescription; and -`PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of -them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown -before any state is stored so a refused config leaves no half-registered plugin -behind. - -`PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that -crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and -left running; acting on that is the host's job in this host-driven library -(#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` -and restart at the level that owns the plugin's lifetime. - -Everything else in the monitor is unchanged: registration, periodic checks, the -`timeout` race and its refd-timer guard (#4875), both failure routes sharing the -counters (#11852), and `successThreshold` binding from every status that records -a failure (#11955). `recovering` is now written only by the success branch — -the one writer that ever meant it. diff --git a/.changeset/plugin-keyed-text-bounds.md b/.changeset/plugin-keyed-text-bounds.md deleted file mode 100644 index 7dbab79d34..0000000000 --- a/.changeset/plugin-keyed-text-bounds.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/plugin-audit": patch -"@objectstack/plugin-security": patch ---- - -fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync (#12059) - -Four text columns that a declared index keys on carried no `maxLength`, so -`driver-sql` emitted them `TEXT`. MySQL refuses a TEXT/BLOB column in a key -without a key length (`ER_BLOB_KEY_WITHOUT_LENGTH`): `CREATE TABLE` succeeds, -`ALTER TABLE … ADD INDEX` fails, and the object lands registered-but-broken -with its declared index silently absent. - -| Object | Column | Bound | Producer the bound is derived from | -|---|---|---|---| -| `sys_activity` | `record_id` | 255 | the physical `id` column — `driver-sql` creates every primary key as `table.string('id').primary()`, knex's `varchar(255)` | -| `sys_audit_log` | `record_id` | 255 | same | -| `sys_audience_binding_suggestion` | `package_id` | 255 | `sys_permission_set.package_id` (255), which the same boot pass writes the same value into | -| `sys_audience_binding_suggestion` | `permission_set_name` | 100 | `sys_permission_set.name` (100), the column this value resolves against at confirm time | - -Each bound is derived from a **named producer** and stated in the declaration -so it is vetoable in review (#11374 route A; PR #12058 is the worked -precedent). None of them narrows anything storable: - -- a record id cannot exceed the `varchar(255)` column the id itself lives in, - and the `referenceVia` seed path refuses an unresolvable pointer rather than - storing a natural key verbatim; -- a permission set name longer than 100 is already refused at the write seam - today — measured on a real engine, `ValidationError: API Name must be ≤ 100 - characters (got 101)` — so no set with such a name can exist, and a - suggestion naming one could never be confirmed. - -Measured at the driver level, shipped declaration vs. the same declaration with -the bounds stripped: `record_id`, `package_id` and `permission_set_name` move -`TEXT` → `varchar(255)` / `varchar(100)`, while `id` reads `varchar(255)` in -both — the transitivity premise, read off a real table rather than assumed. - -Existing deployments are not rewritten: a physical `TEXT` column is deliberately -not diffed against `maxLength` (#11431), so no `ALTER` is planned and no value -at rest is truncated. The repair takes effect where the decision is makeable at -all — at `CREATE TABLE` — because no dialect turns a TEXT column into a keyable -one afterwards. - -Each plugin also gains a keyed-text-bounds pin driven through its **own -registration path** (`init()` → the manifest `register({ objects })` call), -rather than a hand-written object list: the platform-objects pin enumerates only -that package's exports, which is exactly why these four columns escaped route -A's sweep after ADR-0029 K2 moved the objects out. diff --git a/.changeset/plugin-wired-runtime-roster.md b/.changeset/plugin-wired-runtime-roster.md deleted file mode 100644 index 5b95d4f077..0000000000 --- a/.changeset/plugin-wired-runtime-roster.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Add `PLATFORM_PLUGIN_WIRED_RUNTIMES` (and its row type `PlatformPluginWiredRuntime`) to the kernel platform-capability module: a companion provenance roster, keyed by npm package name, for the out-of-repo runtimes that reach the kernel through app `plugins[]` wiring rather than through a `requires` capability token — today `@objectstack/organizations` (loaded by `serve` off the resolved tenancy posture) and `@objectstack/security-enterprise` (which also backs the `hierarchy-security` token). The token-keyed `PLATFORM_CAPABILITY_PROVIDERS` map structurally cannot describe a package that backs no token; this roster makes "is this out-of-repo package real, and where does it ship from?" machine-readable for that population. Provenance only — it adds no capability token, changes no `requires` resolution, and encodes no posture-to-token semantics; drift tests pin the two rosters to agree wherever they name the same package. diff --git a/.changeset/pointer-pair-adoption-five-objects.md b/.changeset/pointer-pair-adoption-five-objects.md deleted file mode 100644 index c967c62cd8..0000000000 --- a/.changeset/pointer-pair-adoption-five-objects.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-audit": minor -"@objectstack/plugin-approvals": minor -"@objectstack/plugin-sharing": minor ---- - -Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. - -The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. - -The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. diff --git a/.changeset/pointer-pair-seed-resolution.md b/.changeset/pointer-pair-seed-resolution.md deleted file mode 100644 index c0ea6591e6..0000000000 --- a/.changeset/pointer-pair-seed-resolution.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-audit": minor ---- - -Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. - -The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. diff --git a/.changeset/public-picker-reference-key-prose.md b/.changeset/public-picker-reference-key-prose.md deleted file mode 100644 index c5eba669ce..0000000000 --- a/.changeset/public-picker-reference-key-prose.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `FormFieldPublicPickerSchema.object` now names `reference` — the key `FieldSchema` actually accepts — instead of the rejected `referenceTo` alias (#13138) - -Both sentences on the `object` key told authors the picker target resolves from -`referenceTo` on the parent object's field definition. `referenceTo` is not a -key `FieldSchema` accepts: it is a **rejected alias**, listed in the field -schema's alias map only so `strictUnknownKeyError` can offer a rename hint when -a parse fails on it. An author who followed the sentence and wrote `referenceTo` -on the parent object's field got their whole object metadata refused at parse — -a failure, not a degraded render. - -Measured against the built `packages/spec/dist/data/index.mjs` with a three-level -control (a two-level one cannot separate "rejected" from "not measured"): - -- `reference: 'sys_user'` (positive control) parses, `parsed.reference` is `'sys_user'` -- `zzz_not_a_key` (negative control) is refused `unrecognized_keys`, with no rename hint -- `referenceTo` is refused `unrecognized_keys`, **with** the hint ``Did you mean `referenceTo` → `reference`?`` - -The `.describe()` half is the load-bearing one: it is published, flowing into the -generated JSON Schema and `content/docs/references/ui/view.mdx`, so the wrong key -name reached authors who never open this file — and AI authors reading the -generated schema as ground truth. The reference docs are regenerated with the -repo's own `gen:docs` in the same change. - -Prose only: no schema shape, no accept/reject movement, no new or removed keys — -every previously-valid input still parses byte-identically. This takes no -position on whether the REST route's legacy-spelling fallback chain survives: -the sentence was wrong under either outcome, because a conformant authored field -carries `reference` in both worlds, and the route already reads `reference` first. diff --git a/.changeset/publish-sys-activity-builtin-vocab.md b/.changeset/publish-sys-activity-builtin-vocab.md deleted file mode 100644 index f28465dbe9..0000000000 --- a/.changeset/publish-sys-activity-builtin-vocab.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-audit': patch ---- - -Publish the built-in `sys_activity.type` vocabulary from `@objectstack/spec` (#11807). - -`SYS_ACTIVITY_BUILTIN_TYPES` (and the derived `SysActivityBuiltinType` union) is now exported from `@objectstack/spec/data` (`feed.zod.ts`, alongside `FeedItemType`), and the `sys_activity` object declaration in `@objectstack/plugin-audit` derives its `type` options from it — one source instead of a hand-copied list per consumer. - -The constant is the platform's **built-in set, not the column's value domain**: `sys_activity.type` stays an open, author-extensible vocabulary (#11507 ruling — an app may contribute values via `activityMilestones[].type`, ADR-0052 §5b.2, or its own inserts, and undeclared values are stored verbatim). It is deliberately a plain `as const` tuple rather than a `z.enum`, so it cannot be used as a validator; consumers must render unknown values, never drop them. UI packages that hand-copied the list (objectui's feed-kind census, which drifted the day #11522 added `scheduled`) can now read this export instead. diff --git a/.changeset/published-packages-declare-exports.md b/.changeset/published-packages-declare-exports.md deleted file mode 100644 index deba1ab004..0000000000 --- a/.changeset/published-packages-declare-exports.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/plugin-hono-server': minor ---- - -feat(cli,plugin-hono-server): declare `exports` maps, and make "a published package declares one" a gate (#12879) - -**BREAKING** removal of reachable subpaths, shipped as `minor` under the repo's -launch-window convention for breaking changes. - -**FROM.** Both packages declared `main` + `files` and no `exports`. Under Node's -resolution that leaves every module under `dist/` importable from outside the -package, whatever the entry barrel names — `@objectstack/cli/dist/utils/lower-callables.js` -resolved, and so did every other `dist/**` path in either package. They were the -only two of the 69 publishable packages here in that shape. - -**TO.** Each declares exactly the entry it means to offer, and nothing else: - -```jsonc -// @objectstack/cli — ESM-only (tsc, "type": "module") -"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } - -// @objectstack/plugin-hono-server — dual build (tsup esm+cjs) -"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } } -``` - -The root entry resolves to exactly what `main` / `types` already pointed at, so -`import … from '@objectstack/cli'` and `require('@objectstack/plugin-hono-server')` -are byte-for-byte unchanged. `@objectstack/cli` uses `default` rather than `import` -on purpose: it is ESM-only, and an `import`-condition-only map would ALSO refuse -CJS `require()`, which is a second break this change is not making. - -The CLI's `bin` is untouched. `exports` gates specifier resolution only, and the -executable is reached by path — the `objectstack` / `os` shims, or -`node node_modules/@objectstack/cli/bin/run.js` as the Dockerfiles and the -showcase Playwright config spell it. - -**Migration.** A consumer that deep-imports `@objectstack/cli/dist/**` or -`@objectstack/plugin-hono-server/dist/**` now gets `ERR_PACKAGE_PATH_NOT_EXPORTED`. -Import the symbol from the package root instead. If it is not exported there, it -was never an offered surface — the deep path resolved by omission, not by -decision, and the fix is an issue naming the use case rather than a map entry -that would ratify it (ratifying accidental reachability prices every later -internal refactor of these packages at a minor bump — the trap this change -exists to close, in the other direction). - -Measured before the maps were written: **one** in-repo deep importer, -`packages/qa/dogfood/test/build-shaped-artifact.ts`, which now reads the CLI's -`lowerCallables` as SOURCE by relative path — the shape the rest of that suite -already uses for package-internal reads, and one that keeps the util internal -(#6293: reach the goal without growing `@objectstack/cli`'s public entry). -`@objectstack/plugin-hono-server` had **zero**. ⚠️ Deep importers OUTSIDE this -repo cannot be measured from inside it; they are the residual risk of this -release, and they break at the import rather than silently. - -**The class, not the two instances.** `pnpm check:published-files` gains a sixth -invariant — GATED: a publishable package declares an `exports` map — turning a -convention that held for 69 of 71 into a written ratchet. It carries the census -control the ruling requires: the gate reads a POSITIVE signal off every manifest, -so a broken reading (no members enumerated, a key read under the wrong name, a -parse that drops manifests) would make "nobody violates GATED" true and green. -A census that finds nobody declaring `exports` therefore fails as an INSTRUMENT -error, in its own words, and the self-test holds the floor inside a band against -the live tree in both directions. - - diff --git a/.changeset/quiet-pugs-shave.md b/.changeset/quiet-pugs-shave.md deleted file mode 100644 index 29f5d301c2..0000000000 --- a/.changeset/quiet-pugs-shave.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/lint": patch ---- - -Correct three stale `current_user` binding claims about a form FIELD `visibleWhen` - -A runtime form field's `visibleWhen` has resolved `current_user` — and the ADR-0068 D1 aliases `user` / `ctx.user` / `os.user` — since objectui#6010, but three texts shipped by these two packages still told authors the root was unbound there, and that per-option `visibleWhen` was "the only `*When` surface where it resolves". - -- `@objectstack/spec`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` now state the binding together with the two limits it does not remove — it is a rendering rule that nothing on the write path evaluates, so a role test written there protects no data; and the scope belongs to the host, so it is empty on the console's public standalone form route, where the predicate faults and visibility fails open. The generated `content/docs/references/ui/view.mdx` rows follow from the `describe()`. -- `@objectstack/lint`: the field-rule prescription no longer grounds "move it to the option's own `visibleWhen`" on exclusivity. It grounds it on enforcement — the rule validator evaluates a per-option predicate on every write — and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule, since moving one there would trade a loud lint error for a silent enforcement gap. - -No schema, validation or verdict change: the set of accepted metadata is byte-identical, and the rule still refuses a user root on an object field-level `*When`. diff --git a/.changeset/radio-multiple-refused-at-schema.md b/.changeset/radio-multiple-refused-at-schema.md deleted file mode 100644 index 7e3bb24227..0000000000 --- a/.changeset/radio-multiple-refused-at-schema.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse an authored `radio` + `multiple: true` at the schema layer (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C) - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's -launch-window convention for breaking changes. - -An author could declare `{ type: 'radio', multiple: true }` and the producer -honoured it everywhere the widget could not: the data layer stored an array, -validated it as multi, split it on import and inferred multi arity for action -params, while the one renderer `radio` has draws a single-value radio group -with zero diagnostics. Declared multi, rendered single — the contradiction sat -inside `packages/spec` itself, where `SINGLE_OPTION_TYPES` calls `radio` -single-choice on one line and `MULTI_CAPABLE_TYPES` carries it on another -because it "shares the select branch". - -Per the maintainer ruling recorded 2026-08-22 on objectui#4015 (Option C, -「接受所有」), `FieldSchema` now refuses the authored combination at parse -time — the seam every publish crosses, both for a standalone `field` document -and for fields embedded in an `ObjectSchema` — with a diagnostic that names -the field, names the illegal pair, and prescribes the correctly-named -multi-choice types: `checkboxes` (all options visible, radio-like layout), -`multiselect` (dropdown) and `tags` (free-form values). - -**What stays accepted, byte-identically:** `radio` without `multiple` -(including its materialized `multiple: false`), `radio` with an authored -`multiple: false`, `select`/`lookup`/`user`/`file`/`image` with -`multiple: true`, and the inherently-multi types with or without the redundant -flag. Because `multiple` materializes `.default(false)`, the refusal can only -ever fire on an authored `true` — a defaulted value never trips it, and -`parse(parse(x))` stays stable (pinned). - -**What is deliberately untouched, per the same ruling:** `MULTI_CAPABLE_TYPES` -and `isMultiValueField` in `field-value.zod.ts` keep `radio`, so data at rest -that was written under the old contract keeps its read path and no -stored-shape migration is paid — that is the whole reason Option C was -preferred over narrowing the sets themselves. A test pins `radio`'s membership -so a future cleanup trips loudly. `packages/objectql`'s record-validator -select/radio branch likewise stays, as a data-safety fallback for stock. - -Measured before landing (both repos, examples/docs/fixtures/tests included): -21 `type: 'radio'` declarations, none carrying `multiple: true` — the refused -combination has zero occurrences, so no existing metadata is invalidated. The -ruling attaches an explicit flip condition: if deployed tenant metadata -carrying the combination with stored data is ever found, entrance rejection -alone would strand it and the set-narrowing option becomes required. - - diff --git a/.changeset/rbac-catalog-seed-refusal-is-loud.md b/.changeset/rbac-catalog-seed-refusal-is-loud.md deleted file mode 100644 index 2af7be5feb..0000000000 --- a/.changeset/rbac-catalog-seed-refusal-is-loud.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): a refused RBAC catalog write is now boot-visible instead of reporting a seed of zero (#12923) - -The five RBAC catalog seeders answered a refused write with `null`/`false`, -which is byte-for-byte the answer for "nothing to do": the `seeded` counter -never incremented and the pass returned normally. On a deployment still -enforcing a **platform-wide** unique index on the name column — the shape that -predates per-organization materialization — every per-organization INSERT is -refused that way, so the boot log read as a successful seed of zero rows. -Measured on a deployed plane, undetected for weeks: an empty Setup (no -positions, no permission sets, no capabilities) under a clean log. - -The outer handler was not missing, it was **disarmed**. `security-plugin.ts` -already wrapped the organization-creation seed in a `try`/`catch` that warns, -and it was unreachable for this failure class: the refusal was converted to -`null` three call layers below, so the `await` resolved normally and the hook -logged "RBAC catalog seeded" at `info` over a seed of nothing. Another outer -`try`/`catch` would fix nothing — the signal has to survive the inner helper, -which is where the change is. - -Each seeder now accumulates the writes the database refused and reports them -**once per object per class per pass**, beside its counts: - -- a **unique violation** is named as a deployment-schema defect, with the - migrate remedy (`os migrate plan` → `os migrate apply`, where the legacy - index surfaces as a `replace_unique_index` operation) and a pointer to the - query engine's own redacted `Insert operation failed` entries, which keep the - colliding index identifier; -- anything **else** gets its own line and is never relabelled as the above, - because no migration repairs it. - -Classification uses the shipped cross-dialect predicates in -`@objectstack/types` (`isUniqueViolationError` / `uniqueViolationColumn`), not -a local `23505` / `ER_DUP_ENTRY` regex. The warning prints only the value-free -`code`/`errno` channel — never the driver's message, which a SQL driver -prefixes with the fully bound statement. - -Diagnosis only: the seeders still **warn and continue**, never throw. A rethrow -would turn a silent degradation into a boot failure on every deployment -carrying the legacy index. Counts, accept/reject behaviour and the healthy-path -logs are unchanged, and a pass that refuses nothing stays silent. diff --git a/.changeset/react-tier-vocab-converge.md b/.changeset/react-tier-vocab-converge.md deleted file mode 100644 index 7ffe742257..0000000000 --- a/.changeset/react-tier-vocab-converge.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying ``'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `` / `` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window. diff --git a/.changeset/record-activity-types-open-vocabulary.md b/.changeset/record-activity-types-open-vocabulary.md deleted file mode 100644 index af2e272115..0000000000 --- a/.changeset/record-activity-types-open-vocabulary.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): open `RecordActivityProps.types` to author-contributed activity kinds (#11658) - -Accept-set **widening** on a published authorable prop, executing the -2026-08-24 maintainer ruling on #11507: `sys_activity.type` is an OPEN, -author-extensible vocabulary — the declared options are the platform built-in -set, ADR-0052 §5b.2 stays a sanctioned author write path, and (verbatim) -"every closed map over this vocabulary is now the bug". - -`RecordActivityProps.types` (the `record:activity` filter, also embedded as -`RecordChatterProps.feed.types`) was that closed map on the authoring surface: -`z.array(FeedItemType)`, a closed enum of the 13 built-in UI kinds, so an -author who contributes an activity type (e.g. `scheduled`, which hotcrm writes -today through the sanctioned ADR-0052 §5b.2 channel) could not name it in the -filter. The element is now `z.union([FeedItemType, z.string().min(1)])`: the -enum branch keeps the built-in kinds visible as guidance (editor autocomplete, -and an `anyOf` member of the generated JSON Schema) while the open-string -branch accepts any author-contributed kind — the union accepts exactly what a -bare non-empty string accepts, so nothing is validated against the built-in -set. A typo'd built-in consequently no longer gets a named rejection; the -ruling accepted that cost rather than re-close the vocabulary. - -Every previously-legal value still parses byte-identically; non-string and -empty entries are still rejected. The `feed.zod.ts` module docblock (and the -generated `feed.mdx` reference page) no longer claims these enums have "no -backend dependency": `FeedItemType` is the target of the map UI consumers -apply to the open `sys_activity.type` column — a backend coupling, not a -backend import. - - diff --git a/.changeset/record-alert-visiblewhen-migration.md b/.changeset/record-alert-visiblewhen-migration.md deleted file mode 100644 index 92799566ac..0000000000 --- a/.changeset/record-alert-visiblewhen-migration.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -Move both authored `record:alert` gates off `properties.visible` onto the -component-node `visibleWhen`, `has()`-guarded and served as a CEL envelope -(#9167) — the `sys_user` detail page's "Email not verified" banner, and the -showcase Task Detail page's "Awaiting review" banner. - -`record:alert` is the one record component that declares a props-level -`visible` predicate, but `PageComponentSchema.properties` is an opaque record: -the bag is served verbatim, so a bare string in `visible` never reaches -`ExpressionInputSchema` and is evaluated by the console's **legacy JS** -evaluator, which has no `has()`. The node-level `visibleWhen` declared at -`page.zod.ts:189` *is* an `ExpressionInputSchema`, so a page that goes through -the spec's transform serves `{ dialect: 'cel', source }` and runs on CEL — the -same engine, and the same `has()` semantics, every other predicate face was -migrated to. - -Three properties of that move were measured in a real console at the pinned -objectui SHA rather than reasoned about, and all three are load-bearing: - -- The `visible` key is **deleted**, not left beside the new gate. A node - `visibleWhen` and `properties.visible` compose as **AND**, so keeping both - would leave the legacy predicate load-bearing and make the migration - cosmetic. -- The `has()` guards are **mandatory**. On the CEL face an absent key is a - *fault*, and that face is fail-soft: measured, an unguarded gate with its key - stripped from the read left the banner VISIBLE, where the guarded gate hid - it. -- On `sys_user` the predicate is authored through `P` so it reaches the wire as - a CEL **envelope**. `SysUserDetailPage` is a raw `Page` object literal, so — - unlike a page built with `definePage()` — nothing normalizes it, and the - renderer keeps bare strings on the legacy path by design. Measured: the bare - form left "Email not verified" showing on *every* profile, including other - people's; the envelope restores every polarity. - -Behaviour for real users is unchanged in every polarity measured — a `todo` -task hides the banner and an `in_review` task shows it; a verified user hides -"Email not verified", an unverified user viewing their own profile shows it, -and another user's profile shows nothing. What changes is that a genuine fault -is now **loud** (CEL names the missing key) instead of silently answering -`false`, and that both predicates sit on the declared slot the platform teaches -everywhere else. diff --git a/.changeset/record-details-headercolor-enum-12126.md b/.changeset/record-details-headercolor-enum-12126.md deleted file mode 100644 index a3be628a8d..0000000000 --- a/.changeset/record-details-headercolor-enum-12126.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare `headerColor` on the strict `record:details` section schema as a closed enum (#12126, maintainer ruling A 2026-08-26). The key was deliberately refused by #11661 because the renderer's only read was a template-literal Tailwind class that generated no CSS; objectui#6294 (merged 2026-08-25) replaced that read with a lookup of complete class literals, so the refusal outlived its recorded reason. The vocabulary is exactly the six tokens that lookup ships — `muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10` — tints only (`CardHeader` sets no foreground, so solids would need a paired `text-*-foreground`). Declared = enforced: `objectstack validate` refuses anything outside the enum at authoring time — including the renderer's `bg-*` pass-through spellings, which render only when the host app's Tailwind build happens to generate the class — instead of shipping a header that silently does not paint. Optional with NO schema default; the `title` withhold from #11661 stands unchanged. diff --git a/.changeset/record-details-section-presentation-keys-11661.md b/.changeset/record-details-section-presentation-keys-11661.md deleted file mode 100644 index 3138bd250d..0000000000 --- a/.changeset/record-details-section-presentation-keys-11661.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare three more `record:details` section keys the renderer has honoured all along (#11661, inheriting the #11289 ruling): `defaultCollapsed` (start a `collapsible: true` section collapsed; renderer default expanded), `icon` (heading icon, lucide name; non-identifier values render as literal text) and `description` (plain-string sub-heading under the section heading). All three were refused by the strict section schema, so `objectstack validate` warned an authored key "did nothing" while the renderer read it. All three are optional with NO schema defaults — the fallbacks stay the renderer's. The same measurement's `title` and `headerColor` deliberately remain refused: `title` is a second spelling of the heading slot `label` declares (held for a declare-vs-converge ruling), and `headerColor` only reaches the DOM as a template-literal Tailwind class that generates no CSS (dead-in-practice; reported as an objectui finding). diff --git a/.changeset/record-details-section-presentation-keys.md b/.changeset/record-details-section-presentation-keys.md deleted file mode 100644 index e3acae611c..0000000000 --- a/.changeset/record-details-section-presentation-keys.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare `hideEmpty` / `collapsible` / `showBorder` on `record:details` -sections — the three keys objectui's renderer has honoured all along - -`RecordDetailsRenderer` spreads every authored section through to -`DetailSection`, which reads all three — but the strict section schema -declared only `name` / `label` / `columns` / `fields`, so -`objectstack validate` warned that an authored key "did nothing". For -`hideEmpty` the warning hid the one key that decides whether a section -exists at all: the renderer forces `hideEmpty ?? true`, and a section whose -fields are all empty then renders nothing — no heading, no skeleton — with -no declarable spelling to ask the skeleton back (a freshly created record -losing two of its three authored sections is how this surfaced). - -Accept-set widening only; the renderer is unchanged (maintainer ruling -2026-08-23, direction 1). All three keys are optional with **no schema -default** — the fallbacks are the renderer's, and the describe() texts -state them as measured at the `.objectui-sha` pin: `hideEmpty` on; -`collapsible` off; `showBorder` derived (on for a titled section, off for -an untitled one). `hideEmpty: false` now keeps a section's label skeleton -on an all-empty record, and schema and runtime finally say the same thing. diff --git a/.changeset/redshift-connect-timeout-bound.md b/.changeset/redshift-connect-timeout-bound.md deleted file mode 100644 index 168adbeefd..0000000000 --- a/.changeset/redshift-connect-timeout-bound.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -A `redshift` datasource now gets the 10s dialect connect-timeout bound instead of silently degrading to the 15s pool backstop (#11784). `SqlDriver` answers three separate questions about a knex `client` name from three separate tables, and `redshift` was a member of the wire-protocol one (`POSTGRES_WIRE_CLIENTS`, which #11389 put it in so it gets the calendar-day parser pin) while absent from `DIALECT_CONNECT_TIMEOUT`. It reaches the server through the `pg` driver — knex's `Client_Redshift` literally `extends Client_PG` — so it has `connectionTimeoutMillis` and would have obeyed it; it just never received it, and `withConnectBound` skipped the injection. Nothing errored and nothing was logged: the bound was simply 50% looser than the method's own docblock declares ("the effective bound" at 10s, with `pool.createTimeoutMillis` a "strictly looser backstop, reached only by a dialect that has no connect-timeout knob (SQLite) or ignores the one we set"). A `redshift` host is neither of those. The practical consequence is the framework#3769 failure shape — an endpoint that accepts the TCP connection and never completes the handshake makes every query WAIT rather than fail, and the wait was bounded 5s later than declared, with knex's inaccurate "the pool is probably full" wording instead of pg's `timeout expired`. A host that sets its own `connectionTimeoutMillis` or `pool.createTimeoutMillis` is still left alone. `redshift` gains **no** SQL-emission identity from this: the connect-timeout knob is a property of the npm driver doing the connecting, not of which DDL dialect gets compiled, so this is independent of the open support-scope decision (#11756). diff --git a/.changeset/referential-field-clear-crud-exemption.md b/.changeset/referential-field-clear-crud-exemption.md deleted file mode 100644 index 952b4e7760..0000000000 --- a/.changeset/referential-field-clear-crud-exemption.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(security): the referential FK-clear write is exempt from the object-level CRUD check (#12597) - -**This changes which deletes succeed** — an observable behavioural contract -change on the delete path, which is why it ships `minor` rather than as a -patch-grade defect repair. - -Deleting a record makes the engine clear every optional lookup that points at it -(`deleteBehavior: 'set_null'`). That cleanup `UPDATE` is engine-owned referential -integrity, and it has carried the server-derived `__referentialFieldClear` marker -since #3023 — but the marker reached only the ownership-anchor guard, so the -write still had to pass the **object-level CRUD check** on the referencing -object. Consequence, measured on a real deployment across 17 role×object pairs: a -role with full delete rights on A and no grant at all on B could delete an A only -while B was **empty**. The moment a real row referenced it, the delete failed with -one generic "you do not have permission", and nothing on any permission screen -showed that deleting A also required write authority on B. - -**What is exempt: the object-level CRUD grant check, and nothing else.** A marked -`update` skips that one gate (both the caller's grant and the ADR-0090 D10 -delegator half of the same question). Everything else in the security middleware -runs unchanged and is pinned test-by-test: - -- field-level security on the FK column still refuses; -- the RLS `using` row scope on the referencing object still refuses; -- the RLS post-image `check` still refuses — so a deployment declaring - `product != null` keeps getting a truthful refusal instead of a silent clear; -- declared validation rules keep firing (they were never in this path); -- a caller without delete rights on the target is still refused; -- an ordinary, unmarked update on the referencing object is untouched. - -⛔ Deliberately **not** `isSystem`: that bypass is total (see -`content/docs/permissions/system-context.mdx` — "Elevation is total, and it is not -granular"), and it would have switched off all three guards above. ⛔ The -`cascade` arm — deleting whole referencing rows — is **unchanged** and still -requires the caller's own delete authority on those rows. - -The write is not elevated at all, so audit attribution is unchanged: the cleanup -`UPDATE` still runs under the operator's identity and lands in the ledger as that -operator (`user_id` / `actor`, and the `updated_by` stamp). - -No authorable surface changes, and no metadata needs migrating: a deployment that -was working around this by granting write access on referencing tables can narrow -those grants, but nothing forces it to. diff --git a/.changeset/registry-collision-order-symmetric.md b/.changeset/registry-collision-order-symmetric.md deleted file mode 100644 index ed9a084abd..0000000000 --- a/.changeset/registry-collision-order-symmetric.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the `[Registry] Collision` warning fires in the cold-boot order too (#12027) - -The artifact-vs-DB collision warning was order-asymmetric, and silent in the -order a kernel boot actually produces. It was guarded on `packageId &&`, so it -spoke only when the PACKAGE registered second — but the artifact reaches the -registry in kernel Phase 1 (`AppPlugin.init` -> `manifest.register`) and the -`sys_metadata` overlay is rehydrated in Phase 2 (`ObjectQLPlugin.start` -> -`loadMetaFromDb`), under the bare name with no package id. The kernel runs -init-all then start-all, so at boot the overlay is ALWAYS the second arrival — -the exact order the guard excluded. The direction that did warn is the -late-registration one: a marketplace install, a post-`start()` -`manifest.register`, an HMR reload. - -The consequence is worse than a missing line, because the mechanism looked -sound to anyone who had seen it work: ADR-0005 says this warning is what makes -the silent shadowing "discoverable in startup logs", and in the only order -startup produces it was not discoverable at all. Measured on a real -`@objectstack/example-crm` boot before the fix: one stored `view` overlay of a -packaged view produced 0 collision lines and 4 silent shadowings (the container -plus its three expanded ViewItems). - -The cold-boot direction now warns with its own message rather than a widened -version of the existing one. Both orders end in the same state — the runtime -row wins either way — but the event differs, and the event is what an operator -acts on: a package that is dead on arrival behind a row that predates it, -versus a stored row taking over a definition this process just loaded from -code. Which definition wins is unchanged in both orders, and pinned as such. - -Graded `patch`: this adds a diagnostic to a path that printed nothing. No API -changes, no accept/reject behaviour changes, and resolution order is untouched. -The one operator-visible effect worth stating is the log itself — a deployment -that customizes packaged metadata will see one new `[Registry] Collision` line -per shadowed name per process, where it previously saw none. Volume was -measured rather than assumed: 0 lines on a stock boot (a stock `sys_metadata` -holds no overlay of a packaged name), and the line marks the transition into -the bare slot rather than the state, so the read-side hydration and the -write-through do not re-emit it on later reads and writes. diff --git a/.changeset/registry-collision-quote-style.md b/.changeset/registry-collision-quote-style.md deleted file mode 100644 index f65ffd112a..0000000000 --- a/.changeset/registry-collision-quote-style.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): single-quote the shadowed package id in the `[Registry] Collision` warning (#12609) - -`patch`: a shipped operator-facing log message changes its punctuation, and -nothing in this repo parses the message beyond substring (`toContain`) test -assertions — no regex or char-level match on the quote character was found -(searched non-test `.ts`/`.tsx`/`.js` across the repo, and `content/`/`docs/`, -for the message text; the two other hits are prose references to the warning -by name, not parsers of its text). No public export, type, or behavior -changes. - -## What changed - -`SchemaRegistry.registerItem`'s cold-boot-order `[Registry] Collision` -warning (`packages/objectql/src/registry.ts`) double-quoted the shadowed -package id — `` `... is shipped by package "${shadowed._packageId}" ...` `` — -against this package's own convention: measured over non-test `.ts` under -`packages/objectql/src`, quoted identifiers in operator prose are -single-quoted 174 times against 37 double-quoted, and this line was one of -the 37. #12563 already settled the same ADR-0005 shadowing fact on the -automation side (`service-automation`) with single quotes -(`package 'crm'`), so an operator whose boot hits both packages' collision -warnings previously read one story in two spellings; this line now matches. - -Byte-identical otherwise. Only the quote character around the interpolated -package id moved. - -## Scope - -One message, one site -(`packages/objectql/src/registry.ts`'s `is shipped by package` warning — -the guard that fires in the common cold-boot order, package registers -first). A sibling `[Registry] Collision` warning in the same file -(`ships from package`, the late-registration-order guard) also -double-quotes its package id and is the same defect class, but is a -different message and a different site — out of scope here, filed -separately rather than swept in. - -## Test - -No existing pin held this message's literal quote character (`toContain(PKG)` -assertions in `registry-collision-order.test.ts` matched either spelling); one -is added — asserting the corrected spelling present **and** the pre-fix -spelling absent, so it is red in both directions, not just green on the fix. - - diff --git a/.changeset/registry-merge-object-definitions-docblock-closed-set.md b/.changeset/registry-merge-object-definitions-docblock-closed-set.md deleted file mode 100644 index d8509261d3..0000000000 --- a/.changeset/registry-merge-object-definitions-docblock-closed-set.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): correct `mergeObjectDefinitions`'s docblock to the real, closed merge set (#12680) - -The docblock on `mergeObjectDefinitions` (`packages/objectql/src/registry.ts`) -said: - -> Fields are merged additively. **Other props: later value wins.** - -The implementation has never done the second half. It merges exactly: -`fields` (additively), `validations` (additively), `indexes` (additively), -and the three guarded scalars `label` / `pluralLabel` / `description` -(last-writer-wins, subject to the `tenantAuthored` yield rule). **Every -other top-level prop an `extend` contributor carries is silently -discarded** — `merged` starts as `{ ...base }` and nothing outside that list -is ever copied onto it. - -This is a **docs-only correction plus a regression pin — zero runtime -behaviour changed.** `mergeObjectDefinitions` is not exported; nothing about -what it does was touched, only what the comment above it claims. Shipped as -`patch` rather than omitted because the file's behaviour is now backed by an -enforced pin (see below) where before it was backed by nothing but an -inaccurate comment — that is a real (if internal-only) improvement to the -package worth a version bump, and this repo's convention reserves "no -changeset" for changes with no user-facing effect of any kind, not for -"no runtime diff." There is no public API surface to widen or narrow (the -function is module-private), so there is nothing here for `check:*` gates -that watch exported shapes to see. - -Why this matters: an author reading the old docblock and shipping an -`objectExtensions` entry carrying, say, `tenancy: { enabled: false }` would -have gotten a **silent no-op** on a security-relevant key — no error, no -warning, the base's existing value simply wins as if the extension had never -named the key. That near-miss is the reason this card exists (found while -resolving cloud#1653's investigation into exactly that override path). The -corrected docblock says the discard out loud; a new pin -(`registry-object-extension-nonenumerated-prop-discard.test.ts`) hands -`mergeObjectDefinitions` (via the public `SchemaRegistry` API) an `extend` -contributor carrying a non-enumerated top-level prop and asserts the merged -result does not carry it, with a guarded scalar as a positive control in the -same fold. `icon` is the pin's fixture — a real, spec-legal, security-neutral -top-level prop — deliberately **not** `tenancy`, so the pin does not read as -license to special-case that key elsewhere. - -⛔ Out of scope, explicitly: implementing "later value wins" for the -undocumented remainder (making `tenancy` / `permissions` extender-writable is -a separate, much larger decision triage fenced off this card) and adding a -runtime warning on the silent drop (a real question, filed separately rather -than folded in here). diff --git a/.changeset/registry-register-object-optional-package-id.md b/.changeset/registry-register-object-optional-package-id.md deleted file mode 100644 index 85efa38c7a..0000000000 --- a/.changeset/registry-register-object-optional-package-id.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql): widen `SchemaRegistry.registerObject`'s `packageId` to optional (#12623) - -**Public-API accept-set widening**, ruled by the maintainer (issue #12623 comment -5434929046, Option A) — shipped as `minor`: it is not a bug fix in behavior (the -underlying runtime path already treated a missing `packageId` as `undefined` -wherever no `tsc` program enforced the parameter's arity; see below), but it does -change the method's declared TypeScript contract, so it gets a real bump rather -than riding along as an implicit patch. - -`registerObject`'s second parameter, `packageId`, was `string` (required) while its -sibling `registerItem` already declared the identical parameter `packageId?: string` -(optional) — and both feed the same downstream call, -`applyProtection(item, { packageId })`, whose own comment documents the -package-less case as intended: *"bare `registerItem(type, item)` calls without a -package context still produce a clean item."* The mismatch made a supported shape -— `registry.registerObject(schema)` with no package context, used by 82 -single-argument call sites across `objectql`, `rest`, `runtime` and `plugins` — a -type error everywhere a `tsc` program actually read the call (14 sites in -`packages/rest`'s test layer, ledgered as `TS2554` against issue #5286; the other -68 sites had no gate reading them, so the error was latent rather than caught). - -**FROM → TO:** - -```ts -// FROM -registerObject(schema: ServiceObject, packageId: string, namespace?: string, ...): string - -// TO -registerObject(schema: ServiceObject, packageId?: string, namespace?: string, ...): string -``` - -No caller needs to change: every existing call already supplied `packageId` (or -relied on JS's lack of arity enforcement to omit it despite the stricter type), and -the runtime behavior for both cases is unchanged — `packageId?: string` carries -**no default value**. A bare call still passes `packageId: undefined` through to -`applyProtection`, which still leaves the registered item provenance-free (no -`_packageId`, no `_provenance`), exactly as it does today for every already-passing -call site. Pinned in -`packages/objectql/src/registry-register-object-optional-package-id.test.ts`, -which asserts on the registered item's key *absence* (not merely -`=== undefined`), paired with a positive control confirming a call that *does* -pass a `packageId` still gets provenance stamped. - -The exported `ObjectContributor` interface's `packageId` field widens from -`string` to `string | undefined` to match — the exact value `registerObject`'s -own parameter is called with, and the mechanically necessary consequence of the -widening above (`ObjectContributor.packageId` is that value's only home). - - diff --git a/.changeset/report-export-column-inference-all-rows.md b/.changeset/report-export-column-inference-all-rows.md deleted file mode 100644 index 5864cd70c4..0000000000 --- a/.changeset/report-export-column-inference-all-rows.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/plugin-reports": patch ---- - -Report CSV and HTML exports now infer their columns from **every** row of the result set, not from the first 50. Column inference sampled `rows.slice(0, 50)` while the projection it produced was applied to all rows, so when a report declared no explicit `query.fields`, any key whose first occurrence fell at row 51 or later was absent from the header *and* dropped from every row that carried it. The export gave no signal: the CSV was well-formed, every row had the same arity, and nothing marked a column as inferred rather than declared, so a recipient of a scheduled report attachment could not tell. Sparse columns are the normal shape of report output — an optional field, a formula only some records satisfy, a lookup that resolves for a subset — and the sampled prefix is the query's first page in its own `orderBy`, so for a report sorted by status or created date the sample correlated with exactly the column it dropped. Both affected renderers are fixed (`csv`, which is also the `default:` format branch, and `html_table`); `json` was never affected. Already-inferred columns keep their position and late-appearing ones are appended, so an export that was correct before is byte-identical now. diff --git a/.changeset/reports-save-input-contract-at-the-door.md b/.changeset/reports-save-input-contract-at-the-door.md deleted file mode 100644 index e779eb052a..0000000000 --- a/.changeset/reports-save-input-contract-at-the-door.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/client": minor -"@objectstack/rest": minor ---- - -fix(client,rest): state `SaveReportInput`'s requirements at the `reports.save` door (#11926) - -**BREAKING** accept-set narrowing on `POST /api/v1/reports` and on the -`client.reports.save` parameter type, shipped as `minor` under the repo's -launch-window convention for breaking changes. - -`IReportService.saveReport` takes a `SaveReportInput`, on which `name`, `object` -and `query` are all required. Nothing on the path said so. The SDK method -declared its parameter `any`, and the route forwarded `req.body ?? {}` straight -through, so the requirement held only as far as each reports implementation -chose to re-derive it privately — the bundled `@objectstack/plugin-reports` does -re-derive all three, but a third-party implementation need not, and a caller -could not tell which one it was talking to. This is the ADR-0078 -declared-but-unenforced shape arriving at an authoring surface: the producer -accepted off-spec input and handed it to a service that requires more. - -Both halves now state the contract: - -- **`client.reports.save(report)`** takes `SaveReportInput` instead of `any`. - Omitting `query` (or `name`, or `object`) is now a compile error at the call - site rather than a surprise from whichever implementation is mounted. The SDK - remains a transport and adds no runtime validation — it is not a second - validator. -- **`POST /api/v1/reports`** refuses a body missing any of the three required - keys, and a `query` that is not a `ReportQuery` envelope (a scalar or an - array), with `400` / `VALIDATION_FAILED` — the same envelope the route - already produced for a service-raised validation error (ADR-0112). A - JavaScript or `curl` caller that never sees the TypeScript type is refused - too. The refusal is ordered **after** the existing `501` for an unmounted - reports service: "no reports service on this deployment" is a deployment fact - and outranks anything about the body. An empty `query: {}` stays legal — - every field on `ReportQuery` is optional — and is pinned as such. - -**Migration.** A caller that omitted `query` was already relying on -implementation-specific behaviour; supply the `ReportQuery` envelope the report -should run (`{}` for "no filters"). Callers already sending a complete -definition are unaffected, and the bundled reports implementation already -refused all three omissions, so no deployment running it changes behaviour — -only the layer that produces the refusal moves, from the service to the door. - - diff --git a/.changeset/rest-api-config-parsed-not-cast.md b/.changeset/rest-api-config-parsed-not-cast.md deleted file mode 100644 index cd31dd81e4..0000000000 --- a/.changeset/rest-api-config-parsed-not-cast.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**BREAKING (accept-set tightening)**: `RestServer` now parses `config.api` -against `RestApiConfigSchema` at construction instead of casting to it, so a -deployment whose `api` config the spec rejects fails loudly at boot rather than -booting into a structurally broken URL space (#11637). - -The regex was always declared. `packages/spec/src/api/rest-server.zod.ts` -constrains `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and -`version` is spliced into `getApiBasePath()` — the base of **every** route this -server mounts. Nothing ran it: both hops into `@objectstack/rest` are casts -(`config.api as any` in `rest-api-plugin.ts`, then `as Partial` -in `normalizeConfig`), the plugin declares no `configSchema`, and the kernel's -`PluginConfigValidator` could not have covered it either — `PluginLoader` -invokes its own `validatePluginConfig(metadata)` with **no config argument** and -returns early, and `createRestApiPlugin` closes over its config so the kernel -never receives it. `??` was the only guard left, and `??` substitutes -`null`/`undefined` only. Measured on the pre-fix code: `api.version: ''` -constructed happily and mounted the whole API — `/data`, `/meta`, `/discovery`, -`openapi.json` — under `/api//`. - -**Newly refused, all at `new RestServer(...)` / `createRestApiPlugin().start()`:** - -- `api.version: ''` — the reported case. Refused with - `Invalid string: must match pattern /^[a-zA-Z0-9_\-\.]+$/`. -- `api.version` carrying any character outside `[a-zA-Z0-9_-.]` — `'v1/beta'` - (which spliced an extra path segment into every route), `'v1 beta'`, `'v1%2F'` - and so on. -- A declared key written with the wrong type: `api.enableCrud: 'yes'`, - `api.basePath: 42`, a malformed `api.documentation` / `api.responseFormat`. - -**Deliberately NOT refused** — the narrowing is exactly what the schema -declares, and no more: - -- `api.projectResolution`. The declared enum is - `z.enum(['required', 'optional', 'auto'])`, but the value this platform - actually ships is **`'none'`**: `@objectstack/runtime`'s - `StandaloneStackResult.api` declares the literal type - `{ enableProjectScoping: false; projectResolution: 'none' }`, and `os serve` - forwards it into this config unchanged (`apiConfig.projectResolution ?? 'auto'` - does not fire — `'none'` is not nullish). Three packages disagree about this - key's vocabulary and have done so silently for exactly as long as nothing ran - the schema. Parsing it here would not settle that disagreement, it would turn - every `os serve` boot into a crash, so the key is `.omit()`ed and the - divergence is filed as #11999. Which spelling wins is a contract question - about project-scoping semantics that this seam cannot answer. -- `api.requireAuth`. The retired key (#3963) is `.omit()`ed from the - validation: it keeps the warn-and-ignore posture `rest-api-plugin.ts` gives - it, and `tsc` still refuses it at any typed authoring site. Converting that - warn into a boot failure is #3963's decision to make, not this seam's. -- Keys no schema in `packages/spec` declares, `api.enableSearch` first among - them. The parse is run for its verdict only and its output is **discarded** — - `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()` strips - what it does not declare, and consuming the parsed value would have silently - turned search back on for a deployment that turned it off. -- `api.basePath: ''` — a bare `z.string()` with no declared constraint stays - accepted. -- `crud`, `metadata`, `batch` and `routes`. Those sub-objects are still cast, - not parsed, and carry unenforced constraints of their own - (`batch.maxBatchSize: z.number().int().min(1).max(1000)`, the - `routes.nameTransform` enum). Same defect class, filed separately — this - change deliberately puts one narrowing in front of contract review, not five. - -**Migration.** Delete or correct the offending key; the refusal names the path, -the declared rule that rejected it, and why an empty version is not survivable. -A deployment that meant "no version segment" wants `api.apiPath: '/api'`, which -sets the base outright and is unconstrained. - -**In-repo blast radius, measured repo-wide.** The census is mechanical, not a -reading: 173 files scanned, 316 `api: { … }` blocks brace-matched, every scalar -literal written at each of the 14 declared keys parsed against the schema this -seam runs. **Two values are refused, and both are the deliberate `''` cases in -this change's own pin file.** Every other in-repo literal is accepted, including -all four `projectResolution` spellings in use and the 96 fixtures carrying the -retired `api.requireAuth`. Nested `documentation` / `responseFormat` literals -exist only in `packages/spec`'s own schema tests, which never construct a -server. Of the 237 construction sites repo-wide, exactly one feeds computed -values (`os serve`), traced to the typed literal in `@objectstack/runtime` -above. One in-repo pin had to be retired: `rest-openapi-route.test.ts`'s falsy -`api.version` case, which carried its own written instruction to retire if -normalization ever started rejecting it, replaced here by a pin on the refusal. - -⚠️ The first version of this census was scoped to `packages/rest` and missed the -five `packages/cli` e2e boots that go through `os serve`; CI caught it. The -radius that matters is every package that CONSTRUCTS a REST server, not the -package the change lives in. - - diff --git a/.changeset/rest-discovery-version-producer.md b/.changeset/rest-discovery-version-producer.md deleted file mode 100644 index faaf150451..0000000000 --- a/.changeset/rest-discovery-version-producer.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -`GET /api/v1/discovery` reports the serving artifact's version instead of the -URL path segment the caller just typed - -`registerDiscoveryEndpoints` called the producer and overwrote the answer one -line later: - -```ts -const discovery = await protocol.getDiscovery(); - -// Override discovery information with actual server configuration -discovery.version = this.config.api.version; -``` - -`config.api.version` is the **API version identifier**, not an artifact -identity. `normalizeConfig()` defaults it to `'v1'`, `packages/spec`'s -`plugin-rest-api.zod.ts` describes it as "API version identifier", and the same -value builds the mount — `getApiBasePath()` returns -``api.apiPath ?? `${api.basePath}/${api.version}` `` → `/api/v1`. So on every -REST-served host, `GET /api/v1/discovery` answered `version: "v1"`: the segment -the caller had already typed to reach the endpoint, on every build of every -release, forever. - -`DiscoverySchema` declares `version` under **System Identity**, grouped with -`name` and `environment` — the "what server is this" question. The #10993 -ruling settled that reading and #11235/#11242 reaffirmed it. The override is -now gone and the producer's derived value reaches the wire. - -**What changes on the wire.** `version` on this endpoint was `"v1"` and is now -the value `getDiscovery()` derives: `OS_RUNTIME_VERSION` when a deployment or -build pipeline stamps one, else the resolved `@objectstack/metadata-protocol` -package version, else `"unknown"`. That is the same stamp `/health` and the -runtime dispatcher's own `/discovery` already read, so the two discovery -producers now give one answer rather than two dialects of one field. Before -#11297 this override masked two producers that genuinely disagreed (`'1.0.0'` -vs `'1.0'`); after it, it was overwriting a value that already agreed. - -**The API-version fact is not lost.** Every entry in the same document's -`routes` is prefixed with the mounted base path, which is built from -`api.version` — recoverable from the same response, in the field that means it. -No schema change, no new field: the accept set and the public surface are -unchanged, and `api.version` still does its real job of building the mount. - -Pinned in `packages/rest/src/discovery-schema-conformance.test.ts`, which drives -the **real** producer through the **real** handler. The assertions pin -provenance, never a literal version string — a stamp injected by the test must -appear on the wire, and the served value must equal what the producer answers -when called directly, including on a server configured with a different -`api.version` (where `routes.data` is asserted to still carry that segment). A -pin spelling a literal would rot at the next release. diff --git a/.changeset/rest-meta-audit-reset-casts-retired.md b/.changeset/rest-meta-audit-reset-casts-retired.md deleted file mode 100644 index 77831ac848..0000000000 --- a/.changeset/rest-meta-audit-reset-casts-retired.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -refactor(rest): the audit and reset door call sites are compiled against the declared contract (#11678, #11679) - -The `GET /meta/:type/:name/audit` and `DELETE /meta/:type/:name` doors in -`packages/rest/src/rest-server.ts` reached their protocol methods through -`(p as any)` — once for each feature-detection guard, once for each call — so -the compiler checked nothing about the request literals they built. The two -casts were load-bearing in opposite ways, both measured: the audit door's on -**member existence** (`auditMetaItem` was undeclared in `packages/spec` -entirely — removing the cast answered `TS2339`), the reset door's on **request -shape** (`deleteMetaItem` was declared, but its request schema carried 2 of the -8 members the door sends — removing the cast answered `TS2353` on six keys). - -With `MetadataProtocol.auditMetaItem` declared and -`DeleteMetaItemRequestSchema` caught up (the spec half of this landing), the -guards are now `typeof p.auditMetaItem !== 'function'` / `if (!p.deleteMetaItem)` -and each request is a named const typed against the spec contract — the reset -door through `TransportScopedMetaRequest` (it still -spreads the transport-level `environmentId`, which stays layered on by the -#9741 envelope rather than becoming a protocol key), the audit door as a plain -`AuditMetaItemRequest` (it stopped sending `environmentId` when #8747 scoped -the read, so there is no transport member left to layer on). - -**No behaviour change of any kind, and nothing about the wire moves.** The -outgoing payloads are byte-identical (same keys, same conditional spreads); the -edits hoist each literal into a const and drop type-level casts. The 501 -feature-detection guards survive on purpose: both members are declared -**optional** (a kernel may implement neither door), and each guard is also what -narrows its member to callable at the call site. An undeclared key in either -literal is now a compile error instead of a payload member no contract has ever -seen. diff --git a/.changeset/rest-package-id-registry-read-refusal.md b/.changeset/rest-package-id-registry-read-refusal.md deleted file mode 100644 index f8188a1bcc..0000000000 --- a/.changeset/rest-package-id-registry-read-refusal.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Stop answering a terminal `404 RESOURCE_NOT_FOUND` for a REGISTRY read that -could not happen on `GET /api/v1/packages/:id` - -The detail door tries the durable `sys_packages` read first and falls back to -the in-memory registry via `protocol.getMetaItems({ type: 'package' })`. That -fallback sat in a bare `catch {} // Protocol unavailable`, so when the read -threw, control fell straight through to the line below and the door answered -**`404 RESOURCE_NOT_FOUND` — `Package "" was not found.`** - -This is the worse half of the family, not a smaller one. The list door's -version of the same swallow (#11130) answered a `200` whose `total` -under-counted; this one answers a terminal negative fact. `404` / -`RESOURCE_NOT_FOUND` is not "the answer may be incomplete", it is *"this package -does not exist"*, and callers act on it: an installer decides the package is not -installed and offers to install it, a console hides the entry, a script branches -to the create path. The producer's own words for the same condition are the -opposite — *"whether this item exists is unknown"*. - -It was also #5532's defect resurfacing one layer up. -`ObjectStackProtocolImplementation.getMetaItems` was taught **not** to report an -unreadable `sys_metadata` as "that item does not exist"; this consumer-side -catch then re-applied precisely that relabelling to the protocol's answer. So -the producer already declares the refusal — every non-benign overlay read -failure leaves as `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code on -the error — and the repair is the same one #11063 made for this door's durable -half and #11130 made for the list door's registry half: delete the catch and let -`sendThrownError` carry the producer's own status and code. - -Standing family ruling — #10965 · #10677 · #10789 · #11063 · #11130: **a read -that could not happen must not be reported as a read that found nothing.** - -Unchanged, and pinned in both directions because the defect was that a failed -read and an absent resource were indistinguishable: a genuine miss (both sources -read fine, neither holds the id) still answers `404 RESOURCE_NOT_FOUND`; a -composition with no protocol service is an absence rather than a failed read and -still reaches that same 404; a registry hit still answers `200` with -`source: 'registry'`; and a durable hit still answers `200` without consulting -the registry at all. No wire field is added — the response shape is a contract -decision this change does not carry. diff --git a/.changeset/rest-package-list-registry-read-refusal.md b/.changeset/rest-package-list-registry-read-refusal.md deleted file mode 100644 index 40d6e2a5ce..0000000000 --- a/.changeset/rest-package-list-registry-read-refusal.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**Fix:** `GET /api/v1/packages` no longer absorbs a failed **registry** read into a `200` — the other half of the two-source merge #11063 closed (#11130). - -The list door merges two sources: the in-memory registry, read through `protocol.getMetaItems({ type: 'package' })`, and the durable `sys_packages` rows, read through `PackageService.list()`. #11063 stopped the door swallowing a failure of the durable half. The registry half still carried its own bare `catch {}` commented *"Protocol unavailable — continue with database only"*, so the exact ambiguity #11063 closed stayed open on the other source: when `getMetaItems` threw, the door answered `200` with `{ packages, total }` built from the database alone, and `total` was presented as a **complete count** either way. The surviving entries kept `source: 'database'`, which reads as provenance rather than as a warning that the registry half is absent — nothing on the wire separated *"these are all the packages"* from *"these are the packages I could still see"*. Same standing family ruling as its sibling — #10965 · #10677 / PR #10788 · #10789 / PR #10964 · #11063: **a read that could not happen must not be reported as a read that found nothing.** - -**The producer already declared its refusal, so this is #11063's edit rather than a new posture.** The card left open whether a *present* `getMetaItems` even has a reachable throw. Measured on the live implementation — `ObjectStackProtocolImplementation` in `packages/metadata-protocol`, the object `MetadataProtocolPlugin` registers as the `protocol` service and the one `RestApiPlugin` hands to this registrar: it routes every non-benign `sys_metadata` overlay read failure through `rethrowUnlessMetadataStoreUnprovisioned` → `metadataStoreUnavailableError`, i.e. `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code **on the error** (#5532) — the same envelope #10965 gave `PackageService.list()`. Driving it with the door's own request answers `THREW status=503 code=SERVICE_UNAVAILABLE` on an `ECONNREFUSED` store. So route (b)'s first leg was already landed and only the second remained: stop swallowing. - -**What changes on the wire.** A registry read that refuses now reaches the client as the producer's own `503` / `SERVICE_UNAVAILABLE` through the declared envelope, instead of a `200` whose `total` lied. A registry read that throws something *undeclared* is answered as the `500 INTERNAL_ERROR` a fault deserves, instead of being eaten. Both were previously indistinguishable from a healthy, complete listing. - -**What deliberately does not change.** An **absent** protocol service is a different, already-handled case — the `if (options.protocol && typeof … === 'function')` guard is untouched, so a composition without the protocol service still answers `200` with the durable half alone. First boot is unaffected: `sys_metadata` not being provisioned yet is not a throw on that path (`isMissingTableError`), it is an empty overlay. The healthy merge — two sources, `source: 'registry' | 'database' | 'both'`, a truthful `total` — is byte-identical. The durable half is untouched (#11063 landed it), and so is the dispatcher twin in `packages/runtime/src/domains/packages.ts`, which serves a single in-memory read with nothing of this shape. - -**No response field is added.** The card's third shape — keep the `200` and make the tolerance visible with a partial-result marker — is a response-shape change, i.e. a contract decision, and was not authorized here. - -Pinned by `packages/rest/src/package-list-registry-read-refusal.test.ts`, which asserts the mechanism rather than "the door still answers": the status **and** the declared `code`, that no `total` is reported over a read that failed, that both halves of the one merge answer the same outage identically, and — as the overreach guards — that an absent protocol service and a healthy merge are unmoved. On the pre-fix tree 4 of its 6 cases fail, every one of them reading `200` where a refusal was owed. diff --git a/.changeset/rest-project-resolution-parsed.md b/.changeset/rest-project-resolution-parsed.md deleted file mode 100644 index 36390145dd..0000000000 --- a/.changeset/rest-project-resolution-parsed.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**BREAKING (accept-set tightening)**: `RestServer` now parses `api.projectResolution` -against `RestApiConfigSchema` at construction, instead of `.omit()`ing it out of that -parse. - -The exemption existed because `@objectstack/runtime`'s standalone stack shipped -`projectResolution: 'none'` — a value the declared enum has never contained — and -`os serve` forwarded it straight in. Because `RestApiConfigSchema` is a non-strict -object, omitting the key meant the undeclared value arrived, was silently stripped as -an unknown key, and took `'auto'`'s branch by fallthrough, while the discovery handler -copied it verbatim into `discovery.scoping.resolution` — publishing a payload the -platform's own `DiscoverySchema` rejects, on every boot. #11999 (PR #12444) settled the -disagreement by migrating the producer onto the declared `'auto'`; this change withdraws -the exemption it justified. - -**What changes for a caller:** `api.projectResolution` must now be one of the three -values the schema has always declared — `'required'`, `'optional'` or `'auto'`. Anything -else, `'none'` included, is refused at construction with a message naming the key. A -census of every `projectResolution` value in this repo found exactly those three plus -the retired one, and the retired one now survives only inside assertions that it is no -longer emitted — so no in-repo boot path is affected. - -If a config of yours is refused, correct the value at its producer. Do not re-add the key -to the `.omit()`: a strategy outside the enum is wrong where it is written, not where it -is read. - - diff --git a/.changeset/rest-publish-meta-item-cast-retired.md b/.changeset/rest-publish-meta-item-cast-retired.md deleted file mode 100644 index 24fd68916c..0000000000 --- a/.changeset/rest-publish-meta-item-cast-retired.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -refactor(rest): the `publishMetaItem` call site is compiled against the declared contract (#11145) - -The `POST /meta/:type/:name/publish` door in `packages/rest/src/rest-server.ts` -reached its protocol method through `(p as any).publishMetaItem` — once for the -501 feature-detection guard, once for the call — so the compiler checked nothing -about the request literal it built. The cast was load-bearing on **member -existence**, not on request shape: #10350 measured that deleting it answered -`TS2339: Property 'publishMetaItem' does not exist on type 'RestProtocol'`, not -a `TS2353` about an unknown key. `publishMetaItem` was an ADR-0076 D9 -server-only extension, so no amount of widening the implementation's own -parameter type in `@objectstack/metadata-protocol` (which this package -deliberately does not depend on) could have retired it. - -#11006 (maintainer ruling 2026-08-22, option B) declared the member on -`MetadataProtocol` with a `PublishMetaItemRequest`, which is what removes the -prop. The guard is now `if (!p.publishMetaItem)` and the request is a named -const typed `TransportScopedMetaRequest` — the same -shape #9741 gave the meta-read doors and #9805 gave the non-door helpers. - -**No behaviour change of any kind, and nothing about the wire moves.** The -outgoing payload is byte-identical (same keys, same conditional spreads); the -edit hoists the literal into a const and drops a type-level cast. Two things -deliberately survive: - -- the 501 feature-detection guard, because the declared member is **optional** - (a kernel may not implement the promotion door at all) — and it is also what - narrows the member to callable at the call site; -- the transport-level `environmentId`, which stays layered on by the - `TransportScopedMetaRequest` envelope rather than becoming a protocol key, per - the #9741 ruling (2026-08-18). - -What the typing buys, measured rather than asserted: an undeclared key in this -request literal is now `TS2353` at compile time instead of a payload member no -contract has ever seen. The docblock that existed only to explain why the cast -had to stay is replaced rather than left behind — a rationale for a prop that no -longer exists is a declaration that outlived its subject. diff --git a/.changeset/retire-auth-scim-provider-schema.md b/.changeset/retire-auth-scim-provider-schema.md deleted file mode 100644 index 5f76c96322..0000000000 --- a/.changeset/retire-auth-scim-provider-schema.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -**BREAKING (public export removed):** `AUTH_SCIM_PROVIDER_SCHEMA` no longer exists. It was a `scimProvider` column mapping exported from `@objectstack/plugin-auth` (via `export * from './auth-schema-config.js'`) that nothing ever read — one repo-wide hit, its own declaration — and that nothing ever could: `@better-auth/scim` hardcodes its model and exposes no `schema` option, still true of the installed `@better-auth/scim@1.7.0-rc.1`, whose `SCIMOptions` declares no `schema`, `modelName` or `fields` member at all. Its sibling constants in the same file are genuinely passed to their plugins; this one had nowhere to go, by construction and by its own doc comment. - -Removed under ADR-0049 enforce-or-remove, because it was a **second source of truth** for the same four column names. The load-bearing one is the adapter layer — `AUTH_MODEL_TO_PROTOCOL` plus the mechanical camelCase-to-snake_case field resolution in `objectql-adapter.ts`, over the `sys_scim_provider` platform object that declares the columns — and it is pinned by the dedicated sso/scim block in `better-auth-schema-parity.test.ts`. A dead copy is worse than none: nothing fails when it drifts from the live names, and the next reader cannot tell which of the two is authoritative. A comment in its place records why no such mapping exists and what owns the names instead, so it is not re-added. - -Behaviour is unchanged. No SCIM column name, platform object, adapter mapping or wire shape moves. - -Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). - - diff --git a/.changeset/retire-compound-name-metadata-addressing.md b/.changeset/retire-compound-name-metadata-addressing.md deleted file mode 100644 index 37f58522bd..0000000000 --- a/.changeset/retire-compound-name-metadata-addressing.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor -'@objectstack/client': minor ---- - -Retire compound-name metadata addressing (`/meta/:type/:section/:name`) - -Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. -Stage 1 declared the item-name grammar and refuses every slash-bearing name at -the publish door, so the routes removed here addressed only names that can no -longer be created. - -**BREAKING — three public REST routes stop answering:** - -| stops answering | use instead | -| :-- | :-- | -| `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | -| `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | -| `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | - -Each retired route folded its `:section` and `:name` segments back into one -slash-bearing key (`views/all_leads`) that the protocol layer then treated as a -single opaque string — the section half was never stored, filtered or -enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. - -The `@objectstack/runtime` dispatcher stops folding in the same way: its -`/meta` handler requires exactly two path segments for an item and three for -`…/published`, instead of re-joining every trailing segment. A `/meta` path -that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than -falling through to the adapter's anonymous 404. - -**FROM → TO for callers.** Address every item through the single-segment route -and percent-encode the name: - -``` -GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads -``` - -`@objectstack/client` now calls `encodeURIComponent` on every `/meta` item -address, so SDK callers need no change: the SDK already sends the new spelling. -Encoding is a **no-op** for every name the item-name grammar admits (lowercase -snake_case segments, optionally dot-qualified), so the bytes on the wire are -unchanged for every name that can be written today. - -A pre-grammar **residue** row whose stored name contains a slash remains -readable, writable and deletable: `%2F` matches the single-segment pattern and -the parameter is decoded back to the stored spelling before the handler runs. -Nothing that could be stored has become unaddressable. - -Two SDK doc comments that promised "compound names pass through unencoded" -(`meta.getPublished`, `meta.publishItem`) are corrected, and the -`SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored -at the compound door and published live — is closed at the source: there is one -door, and it reads every member of the options bag. - - diff --git a/.changeset/retire-lookup-fk-reference-to-branch.md b/.changeset/retire-lookup-fk-reference-to-branch.md deleted file mode 100644 index 776c2fe259..0000000000 --- a/.changeset/retire-lookup-fk-reference-to-branch.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias `reference_to`, and refuse the key instead of honouring it (#11567) - -`SqlDriver.createColumn` emitted `table.foreign(name).references('id')` for a -relationship field carrying `reference_to`. `reference` is the only relationship -spelling `@objectstack/spec` declares — `reference_to` is a **rejected alias**, -answered by `FieldSchema` with `unrecognized_keys` and *"Did you mean -`reference_to` → `reference`?"* — so that branch could not fire for any -spec-conformant lookup, and never had. - -**This is not a behaviour change for any authored deployment.** Measured across -all 44 exported platform objects on live PostgreSQL 16.13 and MySQL 8.0.46 -before the change: **0** FOREIGN KEY constraints. `reference_to` has zero -non-test assignments repo-wide; the branch was reachable only by metadata that -went around Zod through raw `registerObject` (which deliberately skips it). - -What changes is that the driver no longer disagrees with the spec in silence. A -field still carrying `reference_to` at DDL time now throws -`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same -words `FieldSchema` uses, rather than quietly changing the physical schema. One -key, one answer, on both doors. - -Fix, if you have such metadata — the same rename the schema has always asked for: - -| Wrote | Write instead | -|---|---| -| `{ type: 'lookup', reference_to: 'account' }` | `{ type: 'lookup', reference: 'account' }` | - -Referential integrity is unchanged and remains the **engine's**, applied via -`deleteBehavior` (the `409 DELETE_RESTRICTED`) — which is what -`content/docs/protocol/objectql/types.mdx` has documented since 2026-07-30. - -**Not graded as declared-breaking, deliberately.** ADR-0087's ledger reaches -upgraders about *authorable metadata* that must be rewritten. `reference_to` is -not authorable: the spec refuses it at the authoring door today and did before -this change, so no conformant object definition behaves differently and no -migration is owed to any deployment `objectstack migrate meta` can see. The -prescription above exists for metadata that bypassed validation, not for a -surface this repo ever published as writable. diff --git a/.changeset/retire-plugin-metadata-inert-fields.md b/.changeset/retire-plugin-metadata-inert-fields.md deleted file mode 100644 index 48d10dbd1e..0000000000 --- a/.changeset/retire-plugin-metadata-inert-fields.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): retire the inert `PluginMetadata` surfaces — `configSchema` with `PluginConfigValidator`, and `hotReloadable` (#11982, #12587, ADR-0049) - - - -**BREAKING**: removes a published-but-inert capability from the `.` entry of -`@objectstack/core`. Shipped as `minor` under the lockstep launch-window -convention (a `major` bump is refused repo-wide by `check:changeset-no-major`). - -Removed, each measured at zero live consumers with positive controls (the -sibling `startupTimeout` is read live by the kernel's startup timeout guard); -maintainer ruled retire under ADR-0049 enforce-or-remove, 2026-08-27, -decision-inbox batch 5; recorded in ADR-0025 §3.7: - -- `PluginMetadata.configSchema` — declared "Configuration schema for - validation", but the mechanism could never run: the loader's only call - passed no config, and no caller could — plugin factories close over their - config, so the kernel never receives it. Every one of ~40 production - `kernel.use()` compositions already passes config as constructor arguments - and works. -- `PluginConfigValidator` / `createPluginConfigValidator` — the validator - behind that field: real code with zero reachable invocations, deleted along - with its unit test and its export from the security barrel. -- `PluginMetadata.hotReloadable` — declared "Whether plugin supports hot - reload" with zero reads and zero declarations: `HotReloadManager.reloadPlugin` - gates only on its own registered reload configs, so `hotReloadable: false` - was hot-reloaded identically to `true`. -- The `packages/core/ADVANCED_FEATURES.md` example whose inline comment - promised "Config is validated before init is called" — false on the - retired ref, and the retired surface's only in-repo declaration site. - -One-line fixes, per symbol. If you declared `configSchema` on a plugin: -delete the field and parse your config at the plugin's own seam — -`MyConfigSchema.parse(options)` in the plugin factory or constructor, the -pattern `packages/rest` uses. If you imported `PluginConfigValidator` or -`createPluginConfigValidator`: delete the import and hold your own -`schema.parse` call; the compiler (TS2305) locates every such site. If you -declared `hotReloadable`: delete the field — it never gated anything, and -hot-reload participation remains governed solely by -`HotReloadManager.registerReloadConfig`. - -Re-declaring a kernel-owned config-validation surface is a fresh decision for -the day ADR-0025's plugin distribution layer lands, with #11982's zero-caller -measurement as its starting evidence. diff --git a/.changeset/retire-set-user-role-action.md b/.changeset/retire-set-user-role-action.md deleted file mode 100644 index 4f3a312055..0000000000 --- a/.changeset/retire-set-user-role-action.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/spec": patch ---- - -**Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). - -The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). - -**What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. - -**Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. - -`@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. diff --git a/.changeset/retire-sys-scim-provider-object.md b/.changeset/retire-sys-scim-provider-object.md deleted file mode 100644 index 37979f5c30..0000000000 --- a/.changeset/retire-sys-scim-provider-object.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/spec': minor -'@objectstack/plugin-auth': minor -'@objectstack/plugin-security': minor ---- - -**BREAKING (platform object removed):** the `sys_scim_provider` platform object is retired (#11757, ruled on #11693 — leg 1a of the #11632 SCIM epic). - -FROM → TO, per surface: - -- `SysScimProvider` (export of `@objectstack/platform-objects` / `.../identity`) → removed, no replacement export. Fix: delete the import. Stable SCIM state lives on the seven `sys_scim_*` stable-model objects (#3653), and connection credentials on `sys_scim_connection_credential`. -- `sys_scim_provider` in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) → removed. `isPlatformProvidedObjectName('sys_scim_provider')` is now `false`, so a stack referencing the name is flagged as a probable typo instead of resolving. -- plugin-auth: the object is no longer provisioned, and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry — the installed stable `@better-auth/scim@1.7.1` derives no such model, so the entry bridged nothing. -- plugin-security: the `BETTER_AUTH_MANAGED_OBJECTS` write-deny entry for it is gone with the object (the list is pinned bidirectionally against `managedBy: 'better-auth'` declarations). - -The rc.1-era row was written only by the retired `/scim/generate-token` endpoint; after the stable-1.7.1 migration (PR #12726) nothing could write to it. Per the maintainer's ruling (2026-08-24, 「不需要考虑历史数据」; reaffirmed 2026-08-25 — SCIM has no real customers), **no data migration ships**: existing `sys_scim_provider` tables in deployed databases are left untouched — no backfill, no reaper, no migrate command. SCIM-enabled deployments re-register connections on the stable surface; the IdP token reissue is a migration-day operator action regardless of this change. - -The ADR-0066 D3 capability-gate pin moves from the retired object to its surviving sibling `sys_sso_provider`, so the gate posture stays test-pinned. - -Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`) and the #12726 precedent on the same ruling. - - diff --git a/.changeset/retired-defaulted-key-tolerance.md b/.changeset/retired-defaulted-key-tolerance.md deleted file mode 100644 index 6c61ca93e8..0000000000 --- a/.changeset/retired-defaulted-key-tolerance.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Retired-defaulted-key tolerance (#12840, class rule): a RETIRED key that carried a schema default is now refused only when it carries a NON-default value. The retired default parses as inert residue and is STRIPPED — the normalized output does not carry the key, and a parse → serialize round-trip converges to the clean shape. - -Founding case: `permissions[].objects..allowRestore` / `.allowPurge` (#12497). Every artifact built by the published `@objectstack/spec` 17.x has both keys materialized as `false` in every permission entry (the pre-retirement schemas emitted `z.boolean().default(false)`), so the #12497 refusal was sentencing every previously built artifact — marketplace packages, installed environments — to a boot failure on the next runtime upgrade. Those artifacts now parse; `allowRestore: true` / `allowPurge: true` keep the full #12497 refusal with the prescription byte-for-byte, and nothing is un-retired: the keys stay tsc-`never` tombstones on the authoring surface, and the authorable-surface/JSON-schema artifacts still publish the `[RETIRED]` rows. - -Ships as a reusable helper — `acceptRetiredDefaultResidue(schema, residue)` in `packages/spec/src/shared/retired-key.ts` — where `residue` is the retired default captured as a literal at retirement time (never re-read from anywhere live). The next retirement of a defaulted key reuses the helper instead of re-inventing the judgement. diff --git a/.changeset/richtext-code-text-family-emission.md b/.changeset/richtext-code-text-family-emission.md deleted file mode 100644 index 185d67f4a7..0000000000 --- a/.changeset/richtext-code-text-family-emission.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -A `richtext` field now takes an unbounded TEXT column instead of knex's `varchar(255)`, so an ordinary rich-text body over 255 characters can be written (#11794). `createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`; `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`. Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16: before this change the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001 value too long for type character varying(255)`) while the same body in a `markdown` field on the same table was accepted; after it, the column reads back as `text` from `information_schema` on both and the value round-trips byte-identically. `code` moves with it for the same reason. - -Membership is now decided by a stated, measured test instead of the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the **write seam** enforces its declared `maxLength`, which is the invariant `schema-drift.ts` already rests on ("A TEXT column refuses nothing a `maxLength` allows … the bound is enforced at the write seam"). objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else, so both moved types keep a field-named ADR-0112 refusal for an over-declared value and the physical surface is restored to the declared contract rather than widened past it. The set of types that take an unbounded column when unkeyed is pinned as a whole, so the next addition has to be stated on purpose. - -`signature` and `qrcode` are **not** moved, deliberately and against the first reading of this defect. Their stored value is the author's own and routinely far past 255 characters (a data-URI PNG), so `varchar(255)` refuses ordinary values for them too — but the record-validator has no `max_length` branch for either, so an unbounded column would accept values a declared `maxLength` forbids: over-accepting in place of under-accepting, which is a physical surface wider than the contract. They stay bounded until the write seam can bound them, and the live-dialect suite asserts that refusal out loud rather than leaving it undocumented. - -The #11374 keyed-and-bounded rule applies to the two new members unchanged: a keyed, bounded `richtext` / `code` column is still emitted as `varchar(maxLength)` so a declared index can key it on MySQL, and a keyed but unbounded one still gets the named `explainUnkeyableTextColumn` refusal rather than a silently weaker constraint. Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty. diff --git a/.changeset/root-entry-nameability-data-delta.md b/.changeset/root-entry-nameability-data-delta.md deleted file mode 100644 index 3817921d7d..0000000000 --- a/.changeset/root-entry-nameability-data-delta.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): make `BaseValidationRuleShape` and `FilterCondition` nameable from the root entry (#11709) - -Additive follow-up to the #11350 root-entry nameability fix, same invariant -(a type that appears structurally in an entry's public declarations must be -nameable from that same entry): a minimal consumer program with an -un-annotated `export default defineStack(...)` and **no** `@objectstack/spec/data` -import anywhere in its program still failed declaration emit with exactly two -TS2883 diagnostics — `BaseValidationRuleShape` and `FilterCondition`, both -mentioned structurally by `defineStack`'s return type but reachable only -through hash-named internal dist chunks. The root entry now re-exports both -types from their declaring `/data` modules (`data/validation.zod`, -`data/filter.zod`), exactly as #11350 did for `FormFieldInput` / -`NavigationItemInput` / `StateNodeConfig`. No runtime change; existing -consumers that already imported these names from `@objectstack/spec/data` are -unaffected. diff --git a/.changeset/root-entry-type-reexports-11350.md b/.changeset/root-entry-type-reexports-11350.md deleted file mode 100644 index d825c914c7..0000000000 --- a/.changeset/root-entry-type-reexports-11350.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Re-export `FormFieldInput`, `NavigationItemInput` and `StateNodeConfig` from the package root entry (#11350). These types appear structurally in the root entry's own public declarations — `defineStack` returns `ObjectStackDefinition`, declared `z.input`, which the declaration emitter expands structurally rather than preserving as an alias — but they were previously nameable only via the `/ui` and `/automation` subpaths. Any consumer letting TypeScript infer a type through a root-entry function (an un-annotated `export default defineStack(...)`) therefore hit TS2883 naming a hash-named internal dist chunk. With the re-exports, that consumer shape declaration-emits cleanly, with no annotation required. Invariant recorded: a type that appears structurally in an entry's public declarations must be nameable from that same entry. diff --git a/.changeset/run-dev-unbuilt-workspace-lead.md b/.changeset/run-dev-unbuilt-workspace-lead.md deleted file mode 100644 index 8c818804a6..0000000000 --- a/.changeset/run-dev-unbuilt-workspace-lead.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): name the missing build output instead of reporting "command not found" (#12964) - -In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s -every command module while it builds its manifest, every one of them fails, and the run -ends on - -``` -Error: command i18n:extract:… not found -``` - -with exit 2 — while the command file is right there in `src/commands/`. A command whose -module will not load is indistinguishable, to `Config.runCommand`, from one that does not -exist, so the only cause the reader is handed is the one cause that is definitely not -true. - -`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its -own gates and e2e suites, and not part of the published package — now collects oclif's -module-load warnings and, when that failure was caused by a package this repo builds, -prints the attribution and the single command that fixes it ahead of oclif's report: - -``` -objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to -LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'. -The unmet precondition is @objectstack/spec's build output, not the invocation. -objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec -``` - -Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the -module that already answers this question for the gates that shell out to the CLI, so -there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a -command that really is missing keeps oclif's reporting exactly as it was — the diagnosis -requires BOTH oclif's "not found" and a module-load failure naming a workspace package. diff --git a/.changeset/runtime-config-client-error-reporting-dsn.md b/.changeset/runtime-config-client-error-reporting-dsn.md deleted file mode 100644 index acecc7eccd..0000000000 --- a/.changeset/runtime-config-client-error-reporting-dsn.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/cloud-connection": minor ---- - -**Security (p0, upstream half):** `GET /api/v1/runtime/config` now serves the Console's client error-reporting **sink** — the DSN itself, plus the closed set of knobs that travel with it — so a self-hosting operator configures telemetry on the server, in one place, with no frontend rebuild (#12681, upstream half of `cloud#1508`). - -```json -{ - "telemetry": { - "errorReporting": { - "dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42", - "sendDefaultPii": false, - "environment": "production", - "tracesSampleRate": 0.1, - "replaysOnErrorSampleRate": 0 - } - } -} -``` - -An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. The first fix (#10805) served a runtime *permission* and left the *source* where it was — a build-time `VITE_SENTRY_DSN` inlined into the published bundle. That closed the leak and opened a different hole, which the maintainer named on 2026-08-27: - -> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」 - -ObjectStack's users consume a **prebuilt** Console. They cannot set a build-time key, so under the two-key gate a self-hosting operator could not enable client error reporting at all: the permission was reachable and the source was not. - -**The DSN's presence IS the grant.** There is no second boolean, and this is not shorthand — it removes the failure mode the two-key shape had. With a permission and a source configured in different places, "permission on, no DSN" and "DSN in, permission off" are two silent dead states that look identical from the browser. One knob cannot disagree with itself. - -The fail-closed direction survives the collapse for free, and more robustly than the boolean managed: the grant is now "a non-empty DSN reached me", so an older runtime, a third-party host, a 404, a network error, a malformed body and a payload that has not arrived yet all carry no DSN and therefore deny. A boolean needed `=== true` plus a written argument about why `disabled: true` would have been vacuous; absence of a *source* is not a value that can be misread. - -**Everything that must travel with the DSN travels with it.** `sendDefaultPii`, `environment`, `tracesSampleRate` and `replaysOnErrorSampleRate` were build-time `VITE_SENTRY_*` variables, which a prebuilt-console consumer could set none of — including the one deciding whether IP and User-Agent leave the network. This is not new surface; it is the same surface moved to the side that can operate it. One knob deliberately did **not** move: a release identifies *which bundle* produced a stack trace and must match that build's uploaded source maps, so `VITE_SENTRY_RELEASE` stays build-time in objectui and is the only `VITE_SENTRY_*` knob that does. - -**Malformed is refused at mount, never coerced**, and every refusal lands on the safer value. A DSN that is not an `https://PUBLIC_KEY@HOST/PROJECT_ID` URL is refused and the whole block withheld — there is no safe default for a source. A DSN carrying a **secret** after the public key is refused for a different reason: this payload is read by every browser that loads the Console, so a legacy secret-bearing DSN would publish that secret to every visitor while looking entirely ordinary. A bad sample rate falls back to its documented default instead, because silencing error reporting over a typo in a volume knob would be strictness pointed away from the hazard. Quoted values are key-redacted: boot logs travel further than the configuration they quote. - -**A runtime that declared its control plane off serves no sink.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the DSN and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration. - -**Absence is denial, and the reading ships with the contract.** `readClientErrorReporting(payload)` is the canonical fail-closed reader, returning the sink or `null`; a failed fetch is spelled by passing `undefined`, so the error path and the absent path reach the same answer through the same function. It is exported rather than left to consumers because "no DSN means do not send" is a claim about *their* code. - -### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled - -The #10805 permission boolean is removed in this same change — no dual-spelling window. It was added days ago, is **unreleased** (it appears in no published `CHANGELOG.md`), and no deployment consumes it; its pending changeset is superseded by this one rather than shipping a feature and its removal in the same release notes. - -| FROM | TO | -|:--|:--| -| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` | -| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` | -| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` | -| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` | -| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) | - -One-line fix for an operator: replace the `..._ENABLED=true` line with a `..._DSN=` line carrying your DSN. One-line fix for a consumer: `if (readClientErrorReporting(payload)) …` in place of `if (buildTimeDsn && isClientErrorReportingAllowed(payload)) …` — the build-time conjunct is gone, because the server now supplies the source. - -**Landing order is safe in both directions.** An old client meeting this server reads an absent `allowClientErrorReporting` and denies; a new client meeting an old server reads an absent DSN and stays off. Neither half can turn reporting on by itself, so the two repos' PRs can land in any order. - - diff --git a/.changeset/schema-count-counts-the-definitions-shipped.md b/.changeset/schema-count-counts-the-definitions-shipped.md deleted file mode 100644 index 36f4130018..0000000000 --- a/.changeset/schema-count-counts-the-definitions-shipped.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588) - -`json-schema/objectstack.json` ships in the tarball (`json-schema` is in the -package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes -what the field means: "its `x-schema-count` field reports the total number of -definitions". It did not. The generator took the number from `count` — a -counter incremented once per emitted schema — while the bundle's `$defs` is -assembled from a map keyed by `/`. Every def key written more -than once therefore widened a gap nothing reconciled: the published bundle -declared **1596** definitions while carrying **1585**. - -`$defs` is now assembled before the envelope and the field is taken from its -size, so the artifact describes itself. The per-schema files on disk already -agreed with `$defs` (1585) — the same key collapses the file writes — so this -brings the one disagreeing number into line with both of the others, and the -docs sentence is true as written without changing it. - -**The collapsed emits are now named rather than implied.** The 11 def keys -written twice are all **benign self-aliases** — `export const X = XSchema` -spelled as `Object.assign(XSchema, …)`, one schema object reached by two export -names, so the second write cannot change what is published. Eight in `api` -(`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`, -`ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`, -`RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`, -`QueueConfig`, `Task`). No schema is being silently dropped: the existing -`findDefKeyCollisions` guard exits the build on any def key claimed by two -*different* schemas, so a build that produces a bundle at all has only exempt -ones — and `gen:schema` now prints that population instead of leaving it -visible only as a subtraction between two summary lines. - -No schema content changes; only the bundle's self-description and the -generator's console output. diff --git a/.changeset/schema-diff-unreachable-kind.md b/.changeset/schema-diff-unreachable-kind.md deleted file mode 100644 index 2c51e42f1a..0000000000 --- a/.changeset/schema-diff-unreachable-kind.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-datasource": patch -"@objectstack/runtime": patch ---- - -**Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). - -`ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. - -Now: - -- **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. -- **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. -- **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. diff --git a/.changeset/scim-stable-migration.md b/.changeset/scim-stable-migration.md deleted file mode 100644 index 3da1f36288..0000000000 --- a/.changeset/scim-stable-migration.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/plugin-auth": minor -"@objectstack/platform-objects": minor -"@objectstack/spec": minor -"@objectstack/plugin-security": minor -"@objectstack/cli": minor -"create-objectstack": minor ---- - -feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) - -The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, -`/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, -replaced by seven new models and a three-way connection contract. This lands -the migration atomically: - -- **Seven new platform objects** back the stable models — - `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, - `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, - `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, - registered in the platform-object-names registry, listed in - `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose - `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group - provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — - is closed). -- **SCIM connections stay runtime data.** The stable constructor is satisfied - with an application-owned `authentication.verifyBearerToken` that resolves - the connection from a row at request time — not static boot config, and not - the upstream `managedConnections` catalog (deliberately not adopted). -- **ObjectStack owns SCIM credentials outright** (stable upstream stores no - credential at all): `sys_scim_connection_credential` plus - `scim-connection-service.ts` mint/digest/verify. At rest only an - HMAC-SHA-256 keyed by the deployment auth secret (base64url, - domain-separated) is stored — at parity or better than the rc.1 unsalted - SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live - 401 paths for forged, revoked and expired bearers. -- **The ObjectQL better-auth adapter gains native transactions** - (`engine.transaction`, fail-closed on drivers without `beginTransaction`), - which stable scim requires by assertion for atomic provisioning writes. -- **Scaffold suppression retired**: the `@better-auth/scim>better-call` - `allowedVersions` entry (CLI renderer + blank template) is gone — stable - 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped - to absence pins. The `better-auth>better-sqlite3` and four - `@better-auth/utils` entries stay; their retirement conditions are separate - and unmet. -- The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers - `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace - overrides' silencing would "satisfy" while the family is 1.7.1. Floating is - its own follow-up. - -**Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token -endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — -but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a -documented "do not let the IdP push groups" boundary, and the maintainer ruled -(2026-08-25) that SCIM has no real customers and old data need not carry: the -one binding constraint is that an existing system upgrades smoothly, which it -does — every table the installed library can write exists at this version, and -SCIM-disabled deployments see no behavior change. A major would move the whole -fixed version group for a feature surface with zero consumers. Deployments -that had SCIM enabled must mint new connection credentials (digests are not -portable from rc.1 on any path — IdP token reissue is a migration-day -operator action regardless of semver level). `sys_scim_provider` itself is -NOT removed here; its retirement is tracked separately (#11757). diff --git a/.changeset/sdk-response-contracts-bound.md b/.changeset/sdk-response-contracts-bound.md deleted file mode 100644 index f80c8267b3..0000000000 --- a/.changeset/sdk-response-contracts-bound.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/client": minor -"@objectstack/runtime": patch -"@objectstack/rest": patch ---- - -feat(spec,client): bind published response contracts for the 17 unbound client-SDK methods; retire the false `PackageRollbackResponseSchema` (#12038, ruling 1C · 2C · 3A · 4A · 5A) - - - -**BREAKING** export removal, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` `api/PackageRollbackResponse` -plus the D3 semantic entry `package-rollback-response-retired` — where -`os migrate meta` users will look). - -FROM → TO: - -- `PackageRollbackResponseSchema` / `PackageRollbackResponse` / - `PackageRollbackResponseParsed` → `RollbackToPackageCommitResponseSchema` / - `RollbackToPackageCommitResponse` (`@objectstack/spec/api`). The retired - schema declared a VERSION rollback (`{ success, restoredVersion?, - message? }`) while the live `POST /packages/:id/rollback` route posts - `{ commitId }` and answers the ADR-0067 COMMIT rollback — - `{ success, revertedCommits: string[], failed: [{ commitId, error }] }`. - Read `revertedCommits` / `failed`; there is no `restoredVersion`. -- `PackageApiContracts.rollbackPackage` → *(removed)* — it bound the - wrong-operation schema to the exact live path. No route registration or - SDK generation ever consumed it (zero consumers measured across - objectstack, objectui and cloud; only its own unit test and the #11925 - compile-time guard, both updated in this PR). - -One-line fix: replace any import of `PackageRollbackResponse(Schema)` with -`RollbackToPackageCommitResponse(Schema)` and read `revertedCommits` / -`failed` instead of `restoredVersion`. `PackageRollbackRequestSchema` stays -published (ruled out of the retirement), bound to no route. - -The rest of the change is additive — the recorded five-part maintainer -ruling (2026-08-27) for the 17 client-SDK methods that had no published -response contract: - -- **12 describe-only transcriptions** into `@objectstack/spec/api`, each - from the return type its producer already declares inline (no wire byte - changes): `ListDraftsResponseSchema`, `GetMetaDiagnosticsResponseSchema`, - `FindReferencesToMetaResponseSchema`, `RollbackMetaItemResponseSchema`, - `DiffMetaItemResponseSchema`, `ResolvedBookSchema` (authored beside its - interfaces in `system/book.zod.ts`), `DiscardPackageDraftsResponseSchema`, - `ListPackageCommitsResponseSchema` (the `{ commits }` wrapper declared as - the handler's own), `RevertPackageCommitResponseSchema`, - `RollbackToPackageCommitResponseSchema`, - `ReassignOrphanedMetadataResponseSchema`, `DuplicatePackageResponseSchema`. -- **Ruling 1C**: `GetPublishedMetaItemResponseSchema` is deliberately opaque - (`z.unknown()`) — the route answers an arbitrary metadata item body, never - a union frozen against the type registry. -- **Ruling 2C**: `meta.migrateStored` stays UNBOUND, documented at its two - ledger rows and in the SDK — `StoredMigrationReport` lives in - `@objectstack/metadata-protocol`, and a second declaration would drift. -- **Ruling 4A**: `PackageExportManifestSchema` pins the four fixed keys - (`id`, `name`, `version`, `label?`) and stays honestly open for the - registry-derived plural keys. -- **Ruling 5A**: `PackagePublishResultSchema` and the `ResolvedBook` family - are re-exported into `@objectstack/spec/api` (the namespace the - route-ledger resolver searches) — never a second copy. -- The 18 boundable route-ledger rows in `@objectstack/runtime` and - `@objectstack/rest` now name their `responseSchema`, each stating which - surface's envelope it describes; every named schema carries conformance - coverage (the #3877 rule). -- The client SDK binds 16 of the 17 methods to the published payload types, - replaces four invented test mocks with producer-true shapes, and pins the - `unwrapResponse` mis-unwrap hazard so no bound payload can declare both a - boolean `success` and a `data` key. diff --git a/.changeset/sdui-parser-inert-expression-lockstep.md b/.changeset/sdui-parser-inert-expression-lockstep.md deleted file mode 100644 index 597eb2fcd1..0000000000 --- a/.changeset/sdui-parser-inert-expression-lockstep.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/sdui-parser': minor ---- - -html tier: a braced attribute value that is not strict JSON now draws an `inert-expression` warning instead of vanishing silently - -`interpretBrace` materializes strict-JSON values only; anything else — the -single-quoted array every JSX author writes (`columns={['name','amount']}`), -unquoted object keys, any JS expression — compiles to the deferred `{ $expr }` -marker, and nothing downstream evaluates that marker: this tier parses, never -executes (ADR-0080), and no renderer consumes `$expr`. The value reached the -renderer as an opaque object, defensive non-array/non-object reads degraded it -to "not declared", and the author's binding vanished with zero diagnostics -anywhere — a production page's `list-view` rendered its row count and toolbar -with no data columns, through eight `columns` spellings (objectui#6598). That -is ADR-0078's prohibited parsed-but-silently-inert state. - -`validateTree` now emits a warning-severity `inert-expression` diagnostic when a -declared input's value is the `$expr` marker, with the fix in the message: write -the value as JSON (double-quoted strings and keys). - -This is the lockstep port of objectui PR #6613 into this repo's hoisted copy of -the parser. There are two copies, and the invariant is that both agree on the -accepted grammar **and** on diagnostic codes — if they drift, the save gate and -the renderer speak different dialects, and a page can save clean and render -inert. The emitted diagnostic is byte-equal to objectui's. - -Warning, not error, per the objectui#5709 posture for inert authored keys: this -reports an **already**-inert state, so the accept/reject set does not move. -Pages that compiled before still compile, and a warning is non-gating on every -consuming surface in this repo (`runtime-gate` files warnings as advisories, not -as write refusals; `os lint` exits non-zero on error-severity findings only). -The silence is what changed. Escalating the severity, widening the accepted -literal grammar (single-quoted strings, unquoted keys), and wiring the registry -manifest into `validate-jsx-pages` — without which this warning is recorded in -compile output but displayed by no production surface — are separate decisions -tracked on objectui#6614 and its follow-ups. diff --git a/.changeset/sdui-parser-inline-boundary-space.md b/.changeset/sdui-parser-inline-boundary-space.md deleted file mode 100644 index 10e6342074..0000000000 --- a/.changeset/sdui-parser-inline-boundary-space.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/sdui-parser": patch ---- - -The JSX-source parser no longer deletes the space that separates a text run -from an adjacent sibling element. `parseChildren` collapsed each text run's -whitespace to a single space (correct — that is HTML's own whitespace model) -and then `.trim()`ed it (not correct — HTML collapses a whitespace run to one -space, it does not delete it), so `A x page` compiled to -`['A', {strong}, 'page']` and the words ran together wherever that tree is -rendered. - -The rule now applied: collapse the run, then keep one leading space when a -sibling precedes it and one trailing space when a sibling element follows it; -at the parent's own start/end the edge space is still dropped, so -`

hi

` still compiles to `['hi']`. It is deliberately mechanical — it -invents no block/inline taxonomy for a schema tree that has none. Its one -bounded cost is that a whitespace-only run between two siblings survives as a -single space, so a pretty-printed `
    ` gains one `' '` child per inter-item -gap; the tests pin that bound. This matches the rule the downstream copy of -this parser already applies, so the two agree on the tree they produce. diff --git a/.changeset/sdui-parser-unconsumed-widget-option.md b/.changeset/sdui-parser-unconsumed-widget-option.md deleted file mode 100644 index 2b706844c0..0000000000 --- a/.changeset/sdui-parser-unconsumed-widget-option.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/sdui-parser': minor ---- - -html tier: a dashboard widget `options` key that reaches no renderer now draws an `unconsumed-widget-option` warning naming the consumed set - -`@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()` -("declared query keys + open renderer extras"), so ANY key parses, validates -and lints cleanly — including one no renderer reads. That is how a dashboard -shipped `options: { invert: true }` on a gauge with a comment saying what it -was believed to do and rendered the un-inverted measure with no diagnostic -anywhere (objectui#5709). The 2026-08-23 maintainer ruling on that card: open -extras stay open — they just stop being **silent**. A key that reaches no -renderer draws a **warning** naming the consumed set. - -objectui's copy of this parser has emitted that warning since the ruling -landed; this repo's hoisted copy emitted nothing, so the same authored page -produced a diagnostic on one surface and silence on the other — the dialect -split the two copies' invariant forbids (objectstack#12719 — both copies agree -on the accepted grammar **and** on diagnostic codes). `validateTree` now ends -its known-component branch with `checkDashboardWidgetOptions(node)`, and the -new module is a byte-equal port of objectui's save for one token (the emitted -`code` is spelled as an inline literal rather than through the exported -constant, so this repo's ADR-0112 vocabulary gate can classify it — called out -at the site, and pinned equal to the constant by test), so the emitted `code`, -`severity`, `message` and census scope are identical. - -The warning is scoped to the only spec-legal render path: a `dashboard` / -`dashboard-grid` host, a widget with a `dataset`, not in the legacy -`component` format, and not carrying the spec's own -`suppressWarnings: ['unconsumed-widget-option']` escape hatch. The consumed set -is the five keys `DashboardWidgetOptionsSchema` declares (`dateGranularity`, -`sortBy`, `sortOrder`, `limit`, `stageOrder`) plus `description`, the metric -sub-caption channel `translateDashboard` writes into `options`. - -New exports for third-party manifest consumers: `checkDashboardWidgetOptions`, -`CONSUMED_WIDGET_OPTION_KEYS`, `DASHBOARD_WIDGET_HOST_TYPES` and -`UNCONSUMED_WIDGET_OPTION` (the diagnostic code, which is also the id -`suppressWarnings` suppresses). - -Unlike the union-arm port that preceded it, this change is **additive**: it -reports an already-inert state and emits `warning` only, so what this copy -accepts and rejects is exactly where it stood — pinned by a dedicated test. -Today it is latent in the production gate anyway: this repo resolves no -`sdui.manifest.json`, so `validateJsxPages` runs parse-only and `validateTree` -is not reached from it. Wiring that manifest (the second gap recorded on -objectstack#12719, still unowned) is what makes this author-visible, and this -port lands ahead of that wiring deliberately. diff --git a/.changeset/sdui-parser-union-arm-type-mismatch.md b/.changeset/sdui-parser-union-arm-type-mismatch.md deleted file mode 100644 index 0a9ab339a1..0000000000 --- a/.changeset/sdui-parser-union-arm-type-mismatch.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/sdui-parser': minor ---- - -html tier: a union-typed manifest input is now coarse-type-checked over every declared arm instead of drawing no diagnostic at all - -`ManifestInput.type` now carries ONE coarse kind, or an ARRAY of kinds when the -key's contract is a union (objectui#3832). Before this change, this copy's -`checkType` was the older single-arm `switch (input.type)`: a manifest input -declaring a union fell through `default: return null` and drew **no diagnostic -at all** — silence indistinguishable from a value that validated cleanly — -while objectui's copy checked every arm. The same authored page produced -diagnostics on one surface and none on the other: the dialect split the two -parser copies' invariant forbids (objectstack#12719 — both copies agree on the -accepted grammar **and** on diagnostic codes). - -`validateTree`'s coarse check now clears a prop when **any** declared arm -accepts the value, and when **no** arm accepts it emits **one** `type-mismatch` -diagnostic naming every arm — at `error` severity when an `enum` arm is -present (an enum's closed list is the one fact this layer can be certain -about), `warning` otherwise. A single-arm input produces the byte-identical -diagnostic it always did, `invalid-enum` included. `generateDts` emits a -TypeScript union for a union declaration, and `manifestFromConfigs` -canonicalizes union declarations through the new `input-type.ts` module -(`inputTypeArms`, `canonicalizeInputType`, `MANIFEST_INPUT_TYPES` — all -exported, so third-party manifest consumers read arms through the same -accessor the gate does). - -This is the lockstep port of the objectui#3832 ruling into this repo's hoisted -copy of the parser — the ported check is byte-equal to objectui's. It changes -what the save gate accepts and rejects for union-typed inputs: a value fitting -no arm of an enum-carrying union now draws an `error` where it previously drew -nothing. Today that change is latent in the production gate — this repo -resolves no `sdui.manifest.json`, so `validateJsxPages` runs parse-only; wiring -the manifest (the second gap recorded on objectstack#12719) is what makes it -author-visible, and this port lands ahead of that wiring deliberately. diff --git a/.changeset/secret-orphans-command.md b/.changeset/secret-orphans-command.md deleted file mode 100644 index 52f87692dd..0000000000 --- a/.changeset/secret-orphans-command.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -New operator-run command `os secret orphans`: reports the `sys_secret` rows no producer references any more, and — only behind `--delete` — removes the ones the maintainer ruling permits. **Report-only by default: without `--delete` it writes nothing and deletes nothing.** Flags: `--delete`, `--export `, `--declared-datasources `, `--no-declared-datasources`, `--yes`/`-y`, `--database-url`, `--json`. - -It closes the residue #8030 / PR #8063 deliberately left open: that fix reaps forward-only, on the write that retires a handle, so rows orphaned by rotations that already happened on a deployed instance are untouched and nothing else ever touches them. ⛔ Not a boot-time migration and not a `lifecycle`/retention policy — nothing on any boot path reaches this command; age is not unreferencedness, and an age sweep takes the in-force oldest rows first. - -**The deletion predicate is "attributable AND unreferenced by the COMPLETE cross-producer reference union", and incompleteness refuses.** `sys_secret` has three producers and no producer column, so "unreferenced by `sys_setting`" is not "unreferenced": a live, engine-owned credential lands in the settings-scoped classifier's `orphaned` bucket, which is reproduced against the real producers in the new test rather than argued. The command therefore decides on the union of all three holder families and **refuses to delete whenever any family could not be enumerated, naming that family** — the union's three independent gap sources (the host did not declare its code-defined datasources; the engine exposes no `listDatasourceDefs()`; neither reaches) are carried through per family to the operator, to `--json` and into the export, never flattened to a boolean. Saying nothing about declared datasources is a gap, not `[]`: `--no-declared-datasources` is how a host states it has none. - -Three further refusals are mechanical rather than advisory: `unattributable` rows are never deletable; a row whose `(namespace, key)` currently resolves through a **legacy inline** `sys_setting` value is withheld (the #8063 prefix guard in the opposite direction — an inline value names no handle, so it explains the absence); and re-wrap evidence (`version` / `rotated_at`) is reported but is **never** a verdict input, because `rotateKey()` keeps the handle stable and a re-wrap is not a retirement. - -**The pre-delete export is mandatory** and carries the cipher material of every row about to be deleted: the settings audit trail records content digests rather than handles, so without it an erroneous delete could be neither named nor undone. It is CREATED owner-only or not written at all — the write is an exclusive create, so a path that filled up between the early `--export` refusal and the write itself fails rather than truncating a file whose owner and permissions it would then have inherited — and it is read back and checked against the plan before a single row is removed. - -⚠️ The operator-facing text states the measured framing, which is inverted: on the pre-fix rotation path the handle was never repointed, so the value **still in force** is the oldest one — the credential the administrator believed they had replaced — while each orphan holds a value that never took effect. Deleting orphans therefore retires nothing that is exposed, and if the administrator also rotated at the provider the newest orphan may be a credential that is currently valid there. diff --git a/.changeset/secret-reference-union-asks-the-engine.md b/.changeset/secret-reference-union-asks-the-engine.md deleted file mode 100644 index 3db0c06e4a..0000000000 --- a/.changeset/secret-reference-union-asks-the-engine.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the `sys_secret` reference union asks the engine for family 3 instead of trusting every host to remember (#12804) - -Family 3 of the cross-producer reference union — handles held at a datasource -artefact's `external.credentialsRef` — was pure over the artefacts its caller -supplied. `#12758` landed the producer half (`registerDatasourceDef` retains -`external.credentialsRef`, `ObjectQL.listDatasourceDefs()` reads it back), so -the engine could answer the question; the union never asked it. Measured on the -pre-change tree: a datasource registered in code with a bound credentials -handle, with `declaredDatasources: []`, produced a union reporting -`complete: true` while omitting that live handle. A complete-looking union that -is short one live credential is the precondition failure `#8103`'s deletion -predicate rests on. - -The union now assembles family 3 from **three** sources — persisted -`sys_metadata` rows, the definitions the engine holds, and the host's declared -list — as a union, not a replacement. Neither code-side source dominates: the -engine indexes only what was REGISTERED on the runtime, so a config file -nothing ever installed is invisible to it, while a host's list can omit a -datasource a package manifest installed behind its back. - -The declared gap is **re-scoped, not removed**. `declaredDatasources: -undefined` still refuses the whole union, because the residue it covers is -still unreachable: a datasource declared in code that nothing ever registered -reaches neither `sys_metadata` nor `listDatasourceDefs()`. A second refusing -shape joins it — an engine slice that cannot list its definitions gaps the -family rather than contributing an empty answer, symmetric with the host's -`undefined`. In both cases `[]` remains the way to state "there are none". - -`SecretReferenceEngineLike` gains `listDatasourceDefs?()` as an **optional** -member, so every slice that satisfied the port before still satisfies it. The -three prose sites that `#12758` falsified are rewritten rather than trimmed: -the retired mechanism was "the engine drops `credentialsRef`", and the live one -is "the engine's index covers only what was registered, so the residue is -invisible until the host is asked". The operator-facing gap message carries the -new mechanism, and a test pins that it does not carry the old one. - -Bump kept at `patch`, matching `#12663` which created the module: nothing here -reaches the package's entry barrel — `packages/cli/src/index.ts` names no -symbol of this module, and no consumer outside `@objectstack/cli` imports it. diff --git a/.changeset/security-plugin-console-backed-default-sink.md b/.changeset/security-plugin-console-backed-default-sink.md deleted file mode 100644 index a108acbe51..0000000000 --- a/.changeset/security-plugin-console-backed-default-sink.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -`SecurityPlugin`'s own report sink is now **console-backed by default** — loud until a host -injects one — instead of being initialised to an empty object. Its fail-closed refusals -(`getReadFilter … denying (fail-closed, #2852)` and `#4467`, `checkAuthoredRowWrite … -abstaining`, the ADR-0123 tenant-wall refusal) previously went nowhere at all on any instance -whose lifecycle had not yet reached the sink binding; they now reach `console.warn` / -`console.error`. A host that injects a logger is unaffected: `start()` assigns `ctx.logger` -over the default, above both of its early bail-outs (#10706), so a degraded boot still reports -through the host. - -**Operator-visible:** a deployment that never injects a sink will begin seeing these refusals -on the console. That is the intended change — the refusal itself is not moving, only whether -anyone can see it. - -Why `minor` and not `patch`: the observable output of a running deployment changes. The -declared shape changes with it — the field's `warn` channel is now non-optional, which is what -#9754 requires of a sink declaring an optional `error`, and what a default of `{}` made -impossible to state honestly. `error` deliberately stays optional (#9754 option C, falsified: -hosts do inject reduced sinks). The maintainer ruled on 2026-08-24 (#10556) that the default -becomes console-backed and that silent-by-declaration is rejected. diff --git a/.changeset/seed-loader-sole-organization-read-failure.md b/.changeset/seed-loader-sole-organization-read-failure.md deleted file mode 100644 index e6a62932b6..0000000000 --- a/.changeset/seed-loader-sole-organization-read-failure.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): a failed `sys_organization` probe is no longer answered as "no sole organization" (#12852) - -`SeedLoaderService.resolveSoleOrganizationId()` sat behind a bare `catch {}` whose -comment named ONE benign cause — "sys_organization may not exist (single-tenant -runtime)" — while the `catch` swallowed every cause. A dropped connection, a -timeout, a permission refusal or a driver fault all arrived at the caller as -`undefined`, which is not a neutral value here: it is the verdict the method's -own JSDoc calls "genuinely ambiguous", so `load()` stamped no `organization_id` -and every BUSINESS seed row of that run landed org-less — invisible afterwards -under strict org-scoping. Nothing reported it either: `SeedLoadResult` carries an -`errors` field and this path never touched it, so the operator saw a clean, -successful seed. - -The repair is the one already landed on the sibling probe across the engine -boundary (`ObjectQL.probeInstallOrganizations`, #9817), copied: bind the -parameter and ask the declared predicate. Only an unprovisioned TABLE is -truthful emptiness — the exact cause the swallowed comment already named — so -the JSDoc's "or when `sys_organization` is absent" stays true, while every other -cause now propagates with its envelope intact. - -Bump argued, not defaulted: `patch`. No exported signature, type or option -moves, and the declared answer for every case the JSDoc describes is unchanged. -What changes is a failure path — a seed run that used to complete while writing -invisible rows now fails loudly — which is the correction of a defect rather -than a new capability. The three landed repairs in this family (#8896, #8906, -#9817) all shipped as `patch`, and this is the site that pass missed. diff --git a/.changeset/seed-name-lookup-unscoped-page-budget.md b/.changeset/seed-name-lookup-unscoped-page-budget.md deleted file mode 100644 index 64cc912f7c..0000000000 --- a/.changeset/seed-name-lookup-unscoped-page-budget.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): stop reading a truncated existence page as "absent" — the unscoped page cap is now measured, not trusted (#11518) - -`buildExistingByName` (`seed-name-lookup.ts`) is the batched existence oracle the -identity seeders consult in place of a per-item read. Its UNSCOPED page was -capped at `limit: names.length`, which is exact only while one row can exist per -name. Since #8461 / ADR-0120 D1 `sys_capability.name` and -`sys_permission_set.name` are unique **per organization**, and ADR-0066 D1 -explicitly encourages admins to EXTEND the registry inside their own -organization — so one name legitimately carries a row per organization plus the -platform's, and an unscoped page of N names can match far more than N rows. - -The rows that fall off a full page are the highest `id`s under #4363's -`ORDER BY id ASC` tie-breaker, so **whole names vanish from the page** — and a -name missing from the page reads as `absent`, which routes its caller to the -**INSERT** branch. #10103 had already found and repaired exactly this on the -SCOPED arm; the unscoped arm never got the repair, and two seeders on `main` -read unscoped (`bootstrapDeclaredCapabilities`, `permission-set-projection`'s -env-overlay pass). - -⛔ `names.length * 2` would have been the same defect with a larger constant: -rows-per-name is bounded only by the number of organizations, so no constant -multiplier is correct. Instead the cap stopped being a promise and became a -**measurement** — the read asks for one row MORE than it is willing to hold, and -a page that comes back carrying that extra row is a PREFIX of the answer rather -than the answer. It then joins the module's existing "could not answer" causes -and degrades to the per-item read, the fallback already there for a driver -without `$in`. Both directions are exact: no complete page is ever mistaken for -a truncated one, and no truncated page for a complete one. - -**Behaviour change, stated rather than slipped in.** In the truncating case the -two unscoped seeders go from a **silent wrong answer to a loud slow one**: names -that used to be reported `absent` (and re-inserted, or refused by the unique key -as a collision naming a row nobody ever saw) are now answered correctly, at the -cost of one read per name plus a warning naming the object and the budget it -could not fit inside. An install that does not overflow the budget — every stock -one, where a name carries a single row — issues exactly the same single read it -issued before and says nothing. - -The SCOPED arm keeps #10103's cap exactly (`names.length * 2`), because there the -number is a proven bound rather than a budget: `applyTenantScope` returns this -organization's rows plus organization-less ones, and the declared name index is -unique per organization. It gains the same probe, which turns a scoped page that -overflows that bound — reachable only where the unique index is absent or not yet -created — into the same loud degradation instead of a silent truncation. diff --git a/.changeset/seed-pass2-keyless-writeback.md b/.changeset/seed-pass2-keyless-writeback.md deleted file mode 100644 index b9af5edf36..0000000000 --- a/.changeset/seed-pass2-keyless-writeback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Seed pass 2 now writes a deferred reference back through the internal id captured at insert time, so a keyless dataset (`mode: 'insert'`, no `externalId` — the honest authoring for an engine-owned object with no natural key) heals an out-of-order reference exactly like a keyed one. Previously pass 2 re-resolved the source row through its `externalId`, so a keyless (or empty-keyed) row's resolved reference was dropped loudly and the load reported `success: false`; the same load now succeeds with the reference healed. The loud drop remains for the one case where it is true — the source row's pass-1 write failed, so there is nothing to write back onto. Unchanged and now measured against the real engine: a deferred column that is `required: true` is still rejected at pass-1 insert (the deferral deletes the column), so such datasets must still seed their target first. diff --git a/.changeset/seed-required-deferral-early-signal.md b/.changeset/seed-required-deferral-early-signal.md deleted file mode 100644 index be00d7d442..0000000000 --- a/.changeset/seed-required-deferral-early-signal.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): warn at load time when a seed defers a `required` column, before the engine rejects the row (#11674) - -The seed loader defers an unresolvable reference to pass 2 by **deleting the -column** from the pass-1 row. On a `required: true` column that turns the insert -into one the write contract rejects (ADR-0113), so the row never lands and pass -2 has no row to back-fill — measured against the real `ObjectQL` engine in -`packages/objectql/src/engine-seed-required-deferral.test.ts`. It is the second, -independent road to the same loud failure that the pass-2 internal-id write-back -could not clear, and it is why a `required` id half stays **order-dependent** -even though a declared pointer pair contributes no static ordering edge. - -Nothing about that failure was quiet — it is a write error naming the column, a -dropped-deferral error, and `success: false`. What was missing is **when** the -author learns: only after the engine rejected the row, from a driver-level -message that does not mention seeding order. The loader now says it first. - -- **Load-time warning, scoped to the required subset.** When a dataset defers a - reference on a column the write contract requires on insert, the loader logs - one `warn` naming the object, the field, the target object that is not seeded - yet, what deferring did to the row, and the fix — order the target dataset - first. Emitted before the row reaches the engine, once per dataset and field - rather than once per row. -- **⛔ The accept set is unchanged.** Nothing is counted, nothing reaches - `result.errors`, `success` is untouched, and every existing loud failure and - its tests are preserved verbatim. Only the log gains a line. -- **The predicate mirrors the write contract instead of approximating it** - (`required && !readonly && !system`, and only on rows headed for an INSERT). - Two configurations were measured on the real engine where a deferral on a - `required` column is accepted today — an upsert replay taking the UPDATE arm - (an omitted column is not a cleared one) and a `readonly` required column - (required-validation skips it on insert) — and the warning correctly stays - silent on both. They are pinned as controls. - -The constraint is also now documented at the four pointer-pair declaration -sites: `sys_approval_request`, `sys_record_share` and `sys_share_link` must seed -the target dataset first; `sys_audit_log` (optional id half) is genuinely -order-independent and says why it differs. diff --git a/.changeset/seed-tenancy-counter-handoff-writes-first.md b/.changeset/seed-tenancy-counter-handoff-writes-first.md deleted file mode 100644 index 18f4043ba9..0000000000 --- a/.changeset/seed-tenancy-counter-handoff-writes-first.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): the #8686 tenancy backfill writes the merged autonumber high-water mark before it deletes anything (#12394) - -The seed/API tenancy handoff destroyed the counter it was supposed to move. It ran two -independent statements — an `UPDATE` of the organization-scoped `_objectstack_sequences` -row, then an unconditional `DELETE` of the `'__global__'` one — and on a **fresh install** -there is no organization-scoped row yet, because no API create has happened. The `UPDATE` -matched nothing, which is a success on every dialect; the `DELETE` ran regardless; the -counter table was left empty. `SqlDriver.getNextSequenceValue` then re-entered the -`if (!existing)` bootstrap its own docstring reserves for first allocation, re-derived the -counter from `MAX(data)`, and **re-issued a business identifier that had already been -handed out** — measured on 17.1.0: `ACC-000009` on two different records. - -The zero-row case is the *normal* first-boot shape, not an edge case: it is precisely the -shape `buildSplitProbeSql`'s `LEFT JOIN` was widened to catch, so the repair fired on -exactly the installs where its merge loop body never executed. - -The handoff is now one ordered decision per scope: - -1. **write** the merged mark — `INSERT` when the organization-scoped row is absent, - `UPDATE` when it exists; -2. **read it back** — "the statement did not throw" was never evidence a row was written, - and an `UPDATE` matching zero rows is exactly the defect above; -3. **then** retire the `'__global__'` row, addressed by its own stored `key_hash`, so a - retirement can only ever hit the row whose mark was just merged. - -A throw at any step leaves the `'__global__'` row in place — which is the state the next -boot's split probe detects and retries — so a failed repair now loses nothing. - -Per **scope**, because a `{YYYYMMDD}` / `{field}` / per-parent format runs one counter row -per rendered prefix. The old merge was scope-blind in both directions: it could raise every -scope's counter to one merged value, and it deleted every scope's `'__global__'` row. - -The merge rule itself is unchanged and is the 2026-08-15 ruling's: the greater of the two -**counters**, never the data max. That rule is the whole point — a counter is allowed to -sit ahead of its rows (a rolled-back insert burns a number, by design), and that gap is -exactly what the old handoff threw away. - -Graded **patch**: a defect repair inside an existing migration. It adds no export to -`@objectstack/metadata-protocol`'s public index — the new SQL builders are module-scoped -for their own unit tests, matching the index's own recorded rule that an export added so a -test can import a value is the shape to catch before it ships. - -No change to the allocator. Reaching `if (!existing)` is not evidence of lost state — a new -tenant, a new day and a new `{field}` group each reach it legitimately, and a destroyed -counter leaves no row behind to tell the two apart — so a guard there would fire on the hot -path and still not detect this. The repair belongs where the state was destroyed. diff --git a/.changeset/select-option-editability-guidance-bare-id.md b/.changeset/select-option-editability-guidance-bare-id.md deleted file mode 100644 index 4ce29bfd4c..0000000000 --- a/.changeset/select-option-editability-guidance-bare-id.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The select-option editability refusal no longer prints a bare internal issue id. - -Writing `disabled` / `readonly` / `editable` (or their `*When` forms) on a select -option is refused with a prescription explaining that editability is not a -per-option concern. That sentence carried a tracker id an author outside this -repository cannot resolve. It is gone; the citation keeps its durable half — -ADR-0049 and ADR-0068 are named in the same sentence — and the verdict, the -vocabulary and the rest of the wording are unchanged. diff --git a/.changeset/send-template-input-org-retired.md b/.changeset/send-template-input-org-retired.md deleted file mode 100644 index ff4a3fee23..0000000000 --- a/.changeset/send-template-input-org-retired.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `SendTemplateInput.org` — the declared org-overlay resolution tenant id nothing ever read (#11832, ADR-0049 enforce-or-remove) - - - -**BREAKING** accept-set narrowing (compile-time), 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). - -**Removed member:** `SendTemplateInput.org` (`packages/spec/src/contracts/email-service.ts`). - -The member was declared as "Tenant id for org-overlay resolution (when -supported)" and no implementation ever read it: `@objectstack/plugin-email` — -the only `IEmailService` implementation — resolves templates on -`(name, locale)` only, so a caller passing `org` got no org-overlay resolution -and no error; the "(when supported)" hedge was the declaration admitting the -gap. After #11741 landed `organizationId` beside it, `SendTemplateInput` -carried two org-shaped keys of which one did nothing — exactly the shape that -invites an AI author to pick the wrong one. - -**FROM → TO:** `sendTemplate({ template, to, org: tenantId, … })` → -`sendTemplate({ template, to, … })` — delete the `org` key; it never changed -behaviour, so removing it changes none either. It is **NOT** replaced by -`organizationId`: that member is the delivery row's tenant stamp -(`sys_email.organization_id` pass-through, #11741) and opts into no template -overlay resolution. - -**What is refused:** authoring `org` in TypeScript is now an excess-property -`tsc` error (`SendTemplateInput` is a programmatic contracts interface with no -Zod surface, so the compiler is the enforcement channel — pinned in -`email-service.test.ts`). Runtime behaviour is unchanged: nothing ever read -the member, so a JavaScript caller still passing `org` keeps its exact -pre-removal outcome (the key is carried inert and ignored). - -**What stays:** `SendTemplateInput.organizationId` and -`SendEmailInput.organizationId` (#11741, Decision 2 of #11303) are untouched, -semantics included. `RenderTemplateInput` never carried `org`. D3 semantic -entry `send-template-input-org-retired`; no D2 conversion, because the key -only ever appeared in a call-time input bag — no metadata seam ever runs on it -(the `data.engine.update options.upsert` precedent). diff --git a/.changeset/sendemail-organization-id.md b/.changeset/sendemail-organization-id.md deleted file mode 100644 index 452cd5324d..0000000000 --- a/.changeset/sendemail-organization-id.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-email': minor -'@objectstack/service-messaging': minor -'@objectstack/plugin-auth': minor ---- - -Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). - -- `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). -- `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. -- `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). -- `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. - -Forward-stamping only: existing org-less `sys_email` rows are not backfilled. diff --git a/.changeset/senderror-carries-declaredcode.md b/.changeset/senderror-carries-declaredcode.md deleted file mode 100644 index 48372d7882..0000000000 --- a/.changeset/senderror-carries-declaredcode.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/types": minor ---- - -fix(types): let `sendError`'s `extra` carry `declaredCode`, so a nested-envelope route can emit the ADR-0112 open channel (#11719) - -`ApiErrorSchema` has declared `declaredCode` since #9106 — the open, -author-authored channel that carries a metadata app's own `.code` verbatim when -the spelling is not a member of the closed `code` vocabulary. ADR-0112's -2026-08-17 amendment rules that demote **platform-wide**, and #9232 extended it -to the flat `/data` door, which emits the pair today. - -The shared nested-envelope writer could not. `sendError`'s `extra` was typed -`Pick`, so -passing a demoted spelling was a **compile error** and every route answering the -nested envelope dropped it. Nothing invalid shipped — the closed `code` still -carried the member derived from the status — which is exactly what made the loss -silent and one-directional: the author's spelling gone, and a consumer told by -the ADR to read `declaredCode` finding nothing there. Declared-but-unemittable -is a `declared = enforced` gap, closed here at the one writer rather than per -module. - -Additive: `declaredCode` joins the `Pick`. No existing call site changes, no -wire byte moves for any body already being emitted, and the contract's accept -set is untouched — the schema has always permitted the field. - -⛔ Presence still MEANS demotion, and the writer does not re-derive that. The -caller passes `demotedDeclaredCode(thrown)` (`@objectstack/types`), exactly as -the flat door's `thrownCodeFields` does; that helper answers `undefined` when -the producer's spelling is already the vocabulary member sitting in `code`, so a -registered refusal never carries two spellings of one fact. Vocabulary and -position stay two decisions (#9232). - -Pinned in `response-envelope.test.ts` by driving the real pipeline — a -sandbox-shaped throw carrying a tenant-authored `.code` through -`resolveThrownHttpError` and `demotedDeclaredCode` — and by parsing the emitted -body with the real `ApiErrorSchema`, asserting the field is still on it *after* -the parse. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, -so a `.success` assertion alone would have passed against a schema declaring -nothing. diff --git a/.changeset/senderror-carries-usermessage.md b/.changeset/senderror-carries-usermessage.md deleted file mode 100644 index e69394585f..0000000000 --- a/.changeset/senderror-carries-usermessage.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/types": minor ---- - -fix(types): let `sendError`'s `extra` carry `userMessage`, so a nested-envelope route can emit the #9934 user-facing channel (#12404) - -`ApiErrorSchema` declares `userMessage` — the producer-side opt-in for "this -exact text is addressed to the END USER" (#9934; maintainer ruling 2026-08-19 on -objectui#5210, option 1), where **presence IS the marking** and a consumer that -sees the field renders it verbatim instead of substituting its generic string -(#3821 preserved by construction, for everything unmarked). - -Two of the three doors already emit it: the flat `/data` door through -`withDeclaredUserMessage` (`@objectstack/rest`) and the dispatcher door through -`thrown.userMessage` (`@objectstack/runtime`). The shared nested-envelope writer -could not — `sendError`'s `extra` was typed -`Pick`, -so passing the field was a **compile error** and every route answering the -nested envelope dropped it. Nothing invalid shipped, which is what made the loss -silent and one-directional: the author's deliberate, localized refusal text -gone, and a consumer told to read `userMessage` finding nothing there. -Declared-but-unemittable is a `declared = enforced` gap, closed here at the one -writer rather than per module. - -Additive: `userMessage` joins the `Pick`. No existing call site changes, no wire -byte moves for any body already being emitted, and the contract's accept set is -untouched — the schema has always declared the field. - -The channel is live on both ends, which is what makes this a repair rather than -a new declared-but-dead surface: a hook opts in at throw time (host-side, or a -metadata app's sandboxed body whose `e.userMessage` crosses the QuickJS boundary -through `SANDBOX_ERROR_PASSTHROUGH`), and `resolveThrownHttpError` already -carries it onto `ThrownHttpError` for every caller of the shared resolver. - -⛔ Unlike `declaredCode`, this field hands the caller **no invariant to -re-derive**. `declaredCode`'s presence means demotion, so its caller passes -`demotedDeclaredCode(thrown)`; `userMessage`'s presence means only that the -producer opted in, which `declaredUserMessage` has already decided (a non-empty -string, or nothing). The caller passes `thrown.userMessage` straight through, -exactly as the dispatcher door does. That difference is why `extra` stays an -explicit `Pick` rather than being derived from `ApiError`'s optional fields: a -derivation would admit every future optional on the day it lands, with nobody -asked what obligation the channel hands the caller — and these two fields needed -opposite answers to exactly that question. - -Pinned in `response-envelope.test.ts` by driving the real pipeline — a hook -refusal shaped like the one `hook-refusal-user-facing-marking.dogfood.test.ts` -drives, through `resolveThrownHttpError` — and by parsing the emitted body with -the real `ApiErrorSchema`, asserting the field is still on it *after* the parse, -paired with a control showing an undeclared sibling being stripped from the same -body. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, so a -`.success` assertion alone would have passed against a schema declaring nothing. -A blank marking is pinned ABSENT: the writer never invents a marked message for -a producer that wrote none. diff --git a/.changeset/serve-banner-flow-name-shadowing.md b/.changeset/serve-banner-flow-name-shadowing.md deleted file mode 100644 index 6977c2fa9f..0000000000 --- a/.changeset/serve-banner-flow-name-shadowing.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the startup banner names a contested flow name and says which definition is armed (#12028) - -`os dev` / `os start` print an automation summary that reads binding STATE off the -live engine, because a flow that failed to arm emits no log line to go looking for -and the boot-quiet stdout window swallows the engine's own `warn` narration. That -summary was silent about the one failure it could not express as a count. - -The engine's flow map is keyed by BARE name. When a packaged flow and a -runtime-authored `sys_metadata` overlay both claim one name, ADR-0005 precedence -arms one and the loser is not in the map — so it is not in `listFlows()`, not in -`getFlowRuntimeStates()`'s rows, and therefore not in any number the banner -prints. `3 flow(s), 3 bound to triggers` was a true sentence about a set that did -not contain the definition the operator had just edited, and nothing on the banner -said otherwise. #11997 gave the engine the receipt (`getShadowedFlows()`, plus -`armedFrom` / `shadowed` on each runtime-state row) and the automation plugin -warns from it at `kernel:bootstrapped` — but that is a `logger.warn`, which is -exactly the channel this banner exists to work around. - -`collectAutomationSummary` now reads that receipt through a probe feature-detected -exactly like the `getTriggerBindingAudit` one beside it, and the banner prints one -line per contested name carrying all three facts an operator needs: - -``` - ⚠ flow 'send-welcome' is claimed by 2 definitions — a runtime-authored row - (sys_metadata) is ARMED, 1 shadowed (ADR-0005 overlay precedence; only the - armed definition dispatches) -``` - -Naming which body is armed is the point: a line reporting only the count tells an -admin something is wrong and withholds the answer they are standing there to get. - -Silent on every healthy boot — no contested name, no line. This banner is read on -every start, and a warning that also fires when nothing is wrong is one readers -learn to skip. Both directions are pinned on what the banner RENDERS, absence -included. diff --git a/.changeset/serve-node-env-production-default.md b/.changeset/serve-node-env-production-default.md deleted file mode 100644 index cebfb66f22..0000000000 --- a/.changeset/serve-node-env-production-default.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os serve` defaults `NODE_ENV` to `production` when unset, exactly as `os start` already does (#11113) - -**BREAKING for a deployment that runs `os serve` with `NODE_ENV` unset and -relies on a development-class convenience surviving into a real boot.** -Shipped as `minor` under the repo's launch-window convention for breaking -changes, not `patch` — this is a deliberate default flip, not a bugfix that -restores previously-intended behaviour. - -`os start` has forced `NODE_ENV='production'` on the unset case since #5673, -but it does so on the child environment it assembles for its **spawn** -(`start.ts:347`). `os serve` runs **in-process** — there was no equivalent -write, so the whole family of `NODE_ENV !== 'production'` gates across the -tree read the raw `undefined` and took the non-production branch on a boot -that never declared itself anything else. Filed as #11113, the declared -residual of #10366 (which closed the same gate's *set-but-wrong* case and -left this one for its own card, per the disposition precedent on #11035). - -One line: `serve.ts` now defaults `process.env.NODE_ENV` to `'production'` -when unset, at the same point it already defaults it to `'development'` under -`--dev` — before any of the runtime modules it dynamically imports, and before -every gate downstream reads the variable. An explicitly-set `NODE_ENV` -(`development`, `test`, anything else) is never overridden. - -The full behaviour-flip survey — every `NODE_ENV`-reading predicate in the -tree, which ones flip and which don't, and why — is in the PR body (#11113), -not repeated here. Highlights of what an unset-`NODE_ENV` `os serve` boot now -gets, that it did not before: - -- plugin-auth's localhost trusted-origin CSRF substitution closes (the - regression this card pins). -- plugin-auth's CSRF Origin/Referer synthesis for headerless requests closes. -- plugin-auth's missing-`OS_AUTH_SECRET` fallback to a forgeable - `dev-secret-` becomes a refusal to boot instead. -- plugin-auth stops printing invitation / magic-link URLs and OTP codes to - logs. -- plugin-dev's ADR-0115 D6 boot guard now refuses to initialize the dev - assembly (well-known auth secret, seeded dev admin) instead of loading it. -- the SQL driver's auto-DDL guard stops silently applying `safe` schema drift. -- the seed loader stops seeding dev-scoped datasets into what it previously - could not tell apart from production. -- service-settings' local crypto provider now requires a stable key instead - of tolerating an auto-generated / ephemeral one. - -Every one of those is the intended tightening this card exists to make: an -operator (or an AI-authored deploy script) that never exported `NODE_ENV` is -running a real deployment, and the safe direction is to treat it as one, loud -failures included, rather than silently keep a development-class door open. -`NODE_ENV=development` / `NODE_ENV=test` — including the flows `os dev` and -`os serve --dev` already carry — are unaffected; only the unset case moves. - - diff --git a/.changeset/serve-organizations-pkg-roster-key.md b/.changeset/serve-organizations-pkg-roster-key.md deleted file mode 100644 index b8c1928962..0000000000 --- a/.changeset/serve-organizations-pkg-roster-key.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -refactor(cli): `serve` resolves `@objectstack/organizations` through one declaration the spec roster pins (#11614) - -`PLATFORM_PLUGIN_WIRED_RUNTIMES` (`packages/spec`) is the provenance roster for -`plugins[]`-wired out-of-repo runtimes, keyed by npm package name — the single -machine-readable answer to "is this `@objectstack/*` package real, and where -does it ship from?". It exists because a **fabricated** `@objectstack/framework` -sat next to the real `@objectstack/organizations` in published docs for months, -indistinguishable by inspection (#10921). - -`serve` is the only runtime that prints one of those names AT OPERATORS — the -install remedy, the fatal refusal when a walled tenancy posture cannot load the -multi-org runtime, and the degraded-boot warning. It spelled the package as a -bare literal at the resolution site, under no pin at all, so the roster and the -name `serve` actually resolves could diverge in silence: rename the roster key, -or mistype the literal, and every gate stays green while boot reaches for a -package that does not exist and the fatal message tells the operator to install -it. - -The name is now declared once, as `Serve.ORGANIZATIONS_RUNTIME_PKG`, and both -resolution-path uses read it — `importFromHost(…)` and the `readHostDeclaration(…)` -that decides which of the two absence remedies to print. A drift pin in -`serve-capability-vocabulary.test.ts` (the suite that already holds the two -rosters to each other) asserts that value is a roster **key** and that its row is -the `enterprise` edition. - -**Load semantics are untouched.** Which postures load the runtime, the two-stage -import/mount failure classification, and what `OS_ALLOW_DEGRADED_TENANCY` does -and does not cover are all exactly as they were; the roster is deliberately not a -resolution registry and is not consulted at boot. This single-sources the -spelling and nothing else, so there is no behaviour or output change — the -declared value is byte-identical to the literal it replaces. diff --git a/.changeset/serve-organizations-pkg-single-sourced.md b/.changeset/serve-organizations-pkg-single-sourced.md deleted file mode 100644 index 1764d46e4f..0000000000 --- a/.changeset/serve-organizations-pkg-single-sourced.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -chore(cli): spell the multi-org runtime from ONE declaration, with the static keeping its name (#12579) - -`Serve.ORGANIZATIONS_RUNTIME_PKG` carried a second copy of -`@objectstack/organizations`, beside the declaration in -`utils/tenancy-posture-hints.ts` that `os doctor` reads. That duplication was -deliberate: the host-anchoring sweep in `serve-cluster-host-resolution.test.ts` -resolved serve's organizations `import()` through that static and needed a -LITERAL in that file — written as a re-export, the specifier stopped resolving -and the load dropped OUT of the swept population instead of failing inside it -(#11614's silent-vacuity mode, which #12492 hit and measured). That constraint -died at `1ca763b60` (#12533), which taught the sweep to follow an import alias -into a sibling module of the same package. - -The static is now assigned from the shared const. It keeps its NAME — the roster -pin (`test/serve-capability-vocabulary.test.ts`), the sweep and the -rendered-message pins all address `Serve.ORGANIZATIONS_RUNTIME_PKG`, and only the -spelling moved — and the equality assertion that kept the duplication CHECKED -(site 8 of `serve-organizations-message-spelling.test.ts`) retires with its -subject, in the same change. The gap is still never closed in the other -direction: `os doctor` must not depend on a `serve` export to spell a package -name (#12464's coupling ruling, untouched). - -**No behaviour change, measured rather than argued.** Every operator-facing -string that names the runtime — both install-remedy branches, the ADR-0093 D5 -fatal refusal, the degraded-boot warning, the stage-2 mount refusal and the -tenancy-posture fix list — was rendered before and after the change and compared: -byte-identical, 5257 bytes, sha256 `d545248b91e52d05…`. diff --git a/.changeset/serve-organizations-prose-single-sourced.md b/.changeset/serve-organizations-prose-single-sourced.md deleted file mode 100644 index fa76511ec9..0000000000 --- a/.changeset/serve-organizations-prose-single-sourced.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -chore(cli): spell the multi-org runtime from its one declaration in `serve`'s operator-facing prose (#12151) - -`serve` prints `@objectstack/organizations` at operators in five places: the -two-branch install remedy, the ADR-0093 D5 fatal refusal when a walled tenancy -posture cannot load the runtime, the degraded-boot warning, the stage-2 mount -refusal, and the `isolated` posture description in the tenancy-posture fix list. -#11614 single-sourced the name `serve` RESOLVES onto -`Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the -spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster, but the sentences kept their -own copies — so a roster-key rename would leave operator instructions naming a -package that no longer exists while boot reached for the new one, with every gate -green (the roster pin only sees the declaration). - -All five now interpolate the constant, and a new test asserts what they RENDER -rather than that the constant appears in the source — the affected line compared -whitespace-included against text built from the same declaration. The message -bodies moved into pure formatters (`formatOrganizationsInstallRemedy`, -`formatOrganizationsAbsentFatal`, `formatDegradedTenancyWarning`, -`formatOrganizationsMountFatal`), the seam shape `resolveTenancyPostureOrRefusal` -in the same file already uses, so the rendering is reachable without spawning a -boot; `chalk` and the `process.exit` stay at the call site. - -**No behaviour change.** The declared value is byte-identical to the literal it -replaces, and the rendered output was verified byte-for-byte against the -pre-change expressions across every branch (both remedy kinds, both walled -postures, `mountCode` present and absent). The three comments in `serve.ts` that -legitimately name the package are untouched — the tempting "no bare literal -outside the declaration" source scan is deliberately not built, since it would -have to exclude comments and that shape is easy to get wrong. diff --git a/.changeset/serve-publishes-bound-port.md b/.changeset/serve-publishes-bound-port.md deleted file mode 100644 index ebba2c0a98..0000000000 --- a/.changeset/serve-publishes-bound-port.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os serve` publishes the port it BOUND, not the one it was asked for (#13062) - -The three channels `serve` announces an address on — the `objectstack:listening` -IPC message, the ready banner's `API:` row and `runtime..json` — -were three outputs of one number, and that number was the port the operator -requested. For every port but one the requested and the bound value coincide, so -this stayed invisible; `0` is the value where they cannot coincide. -`MIN_PORT = 0` is legal on purpose (`utils/port-contract.ts`: 0 is "a REQUEST, -not an error" — `listen(0)` binds a kernel-assigned port). - -FROM (`os serve --port 0`): IPC `{ port: 0, url: 'http://localhost:0' }`, banner -`API: http://localhost:0/`, `runtime.env_local.json` `"port": 0` — three -channels naming an address nothing was listening on, with nothing erroring. - -TO: all three name the port the HTTP server actually bound, read off the -transport's own `IHttpServer.getPort()` (the contract member that already -promises "the real bound port — in particular when `listen(0)` requested an -ephemeral port"). The same repair covers a bind that walked past a port taken -between `serve`'s probe and the transport's `listen()`. - -Unchanged for every other port: when the requested port is the bound one — which -is every ordinary boot, including one that dev-auto-shifted off a busy port — -all three channels publish exactly what they published before. A boot with no -HTTP server, or a transport that does not implement the optional member, also -falls back to the previous value. diff --git a/.changeset/service-analytics-typecheck-gate.md b/.changeset/service-analytics-typecheck-gate.md deleted file mode 100644 index 650574f848..0000000000 --- a/.changeset/service-analytics-typecheck-gate.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): wire the `typecheck` script so turbo stops silently no-opping the gate, and clear the 10 type errors it was hiding (#12939) - -`packages/services/service-analytics/package.json` declared only `build` and -`test`. Root `typecheck` is `turbo run typecheck`, which **no-ops a package -that has no such script and reports success** — so no tsc read this package's -`src/` from the typecheck lane at all. `build` is tsup (esbuild; the DTS pass -processes declarations only) and `test` is vitest (esbuild transform), and -neither type-checks. The package was reached only by the `check:type-check-debt` -ratchet, which asserts the error count does not *grow* — never that it is zero. - -Adding the one-line script (mirroring its sibling `service-settings`, repaired -the same way in #7925) makes the task real. The tests are already inside the -program — the package `tsconfig.json` includes `src` and the tests live in -`src/__tests__/**` — so `tsc --noEmit --listFiles` lists **83 of the 83** -`*.test.ts` files on disk. The new gate reads the tests, not just the source. - -All 10 errors were stale tests, not source defects; no non-test source file -changed. Nothing was silenced: no `any` added, no `@ts-expect-error`, no -`@ts-nocheck`, `strict` untouched, and the tsconfig `include`/`exclude` are -byte-identical — excluding the tests would have converted a missing gate into -a lying one. - -- `__tests__/measure-source-field-gate.test.ts` (7 x TS2339). `promise.catch(fn)` - does not drop the resolved branch from the type, so - `service.query(...).catch((e) => e as Error)` was `AnalyticsResult | Error` - and every `err.message` / `err.field` / `err.member` / `err.param` read was a - property access on `AnalyticsResult`. A local `refusalOf()` helper narrows it - once via `then`; as a bonus the resolved branch now fails by - name instead of surfacing later as `expect(undefined).toMatch(...)`. -- `__tests__/objectql-timedimension-projection.test.ts` (2 x TS7053). The - `TABLE` fixture was inferred as `{ id: number; due_date: string; priority: - string }[]` and the aggregate stand-in indexes it by a computed `string` key. - Annotated as the `Row` (`Record`) the file already declares. -- `__tests__/analytics-service.test.ts` (1 x TS6133). An unused - `AnalyticsDriverCapabilities` type import. The capability literals in this - file are inline `ctx` objects checked contextually at each `canHandle` call - site, so the import added no coverage and is removed. - -`service-analytics` graduates out of the `check:type-check-coverage` DEBT -ledger: 65/78 -> 66/78 workspace packages type-checked, 382 -> 372 frozen raw -errors, 13 -> 12 ledger entries. diff --git a/.changeset/settings-getmany-all-or-nothing-doc.md b/.changeset/settings-getmany-all-or-nothing-doc.md deleted file mode 100644 index 8638452552..0000000000 --- a/.changeset/settings-getmany-all-or-nothing-doc.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -docs(service-settings): state `getMany`'s all-or-nothing key validation on the declaration that owns it (#11680) - -Documentation and a pin. **No behaviour change** — the accept set and every -resolved value are byte-identical. - -`SettingsService.getMany` validates **every** requested key against the -namespace manifest before it reads a single env override and before it loads a -single row, so one undeclared key rejects the whole call with -`UnknownKeyError` (`code: 'SETTINGS_UNKNOWN_KEY'`) and the caller receives -nothing — not the subset it was entitled to. N per-key `get()` calls behave -differently on exactly that input: each declared key still answers, and only -the undeclared one throws. - -The doc comment was otherwise detailed — it explained the grouped row load and -the env-override ordering, and claimed row-for-row equivalence with per-key -`get` "BY CONSTRUCTION" — but never drew this line. That equivalence claim -holds for every key that *resolves* and not for the refusal, so a batched -consumer had to rediscover the rule from a test. `resolveLocalizationContext` -was the first to inherit it and had to record the consequence locally: a host -registering a **partial** `localization` manifest loses all its keys at once -and drops to a shorter cascade, where the per-key path would still have -resolved the declared ones. - -`getMany`'s doc comment now states the rule, its blast radius, why validating -ahead of the grouped walk makes the refusal independent of key order and scope -grouping, and what a caller on a partial manifest should expect. - -The pre-existing pin asserted only `rejects.toThrow(/nope/)` — green whatever -the blast radius is. A sibling pin now asserts the property instead: the error -envelope (`code: 'SETTINGS_UNKNOWN_KEY'`), that **zero** rows were loaded -(the refusal is up-front, with the undeclared key last in the request), and -the contrast that per-key `get()` still answers each declared key. diff --git a/.changeset/settings-loadrows-upper-scope.md b/.changeset/settings-loadrows-upper-scope.md deleted file mode 100644 index f98df1819e..0000000000 --- a/.changeset/settings-loadrows-upper-scope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -`loadRows` user-keyed engine loads now include tenant/global rows (`$or` over `user_id`/upper scopes), mirroring the in-memory branch. Fixes the user→tenant→global read cascade dying at the user level and upper-scope locks never firing on user-scope writes, on engine-bound deployments (#11228). diff --git a/.changeset/setup-packaged-automation-nav-entry.md b/.changeset/setup-packaged-automation-nav-entry.md deleted file mode 100644 index 0f94800058..0000000000 --- a/.changeset/setup-packaged-automation-nav-entry.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): add the Setup nav entry for the packaged-automation page (#12457, ADR-0126 §7.4) - -The packaged-automation page (on/off per packaged flow/action, clone for -flows) shipped complete in the console (objectui app-shell, registered under -the component ref `automation:packaged`), but no framework -`NavigationContribution` ever named that ref — so on every stock boot the -page was reachable only by a hand-typed URL -(`/apps/setup/component/automation/packaged`), and Setup's sidebar carried no -entry. Epic #12150's L5/L6-UI cards closed with the objectui half pinned and -the framework half missing; `content/docs/build-without-code.mdx` promises -the page publicly. - -`SETUP_NAV_CONTRIBUTIONS` now contributes `nav_packaged_automation` -(`type: 'component'`, `componentRef: 'automation:packaged'`) in `group_apps` -beside Packages — package administration is Operate (ADR-0084), and ADR-0126 -§7.4 rules "Studio keeps the editing; Setup gets the operational state". The -entry deliberately carries no `requiresService: 'automation'` (the action -switches ride the `sys_metadata_activation` ledger this package registers and -work on compositions with no automation service, #12419) and no -`requiredPermissions` (matches `nav_packages`: the app's `setup.access` gates -entry, the activation write doors enforce `manage_metadata` / the §5 operator -gate server-side). Labels land in all four locales with recorded source -hashes; `setup-packaged-automation-nav.test.ts` pins the framework half of -the cross-repo contract the objectui nav test pins from its side. diff --git a/.changeset/shadow-carried-unique-is-not-index-drift.md b/.changeset/shadow-carried-unique-is-not-index-drift.md deleted file mode 100644 index 0e48aa2f2c..0000000000 --- a/.changeset/shadow-carried-unique-is-not-index-drift.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A healthy hash-shadow-carried UNIQUE is no longer reported as destructive index -drift — and the remedy that used to be proposed for it would have DROPPED the -constraint - -On MySQL a declared UNIQUE whose key is too wide for an InnoDB key part is -carried by a driver-owned generated column holding a SHA-256 of the key values -(#11627). The index differ compared the declared columns against the columns an -index physically KEYS, so a shadow-carried UNIQUE — one VARBINARY(32) generated -column as the whole key — could never match. A clean `initObjects` reported the -index the same boot had just created as `index_mismatch` / `destructive`, with -`recreate_index` as the remedy and `os migrate apply --allow-destructive` in the -message. - -Following that advice removed a live uniqueness guarantee. `recreate_index` -drops the UNIQUE by name and re-runs the additive sync; the sync retakes the -shadow route, and its `ALTER TABLE … ADD COLUMN` then failed on the generated -column that **survived** the index drop. That failure is a duplicate-COLUMN -error, matched by neither the "already exists" absorb (which spells index names) -nor the unique-violation branch — so the apply ended with the constraint dropped -and not re-created. - -Both halves are fixed, and they share one vocabulary rather than special-casing -the differ. The orphan-COLUMN pass already recognised the shadow as driver-owned -(`isHashShadowColumn`) while the index it carries was proposed for destructive -rebuild; that asymmetry was the shape of the defect. - -- The shadow's name derivation moved next to that predicate, so the name the - sync creates and the name the differ looks for have one definition. -- Introspection reads the shadow's stored `GENERATION_EXPRESSION` and records - the key it actually hashes, so the differ compares the key the constraint - **enforces** instead of the digest column it stores. Drift reports and plan - messages now name that key too, rather than `UNIQUE (uniq_…__hash)`. -- The sync inspects a surviving shadow column instead of assuming it absent: a - column already hashing the declared key is re-keyed in place, one hashing a - different key is re-generated, and a non-generated column of that name is - refused rather than dropped. - -Deliberately a real key comparison and not a blanket skip of every shadow. A -shadow written before #12998 hashes the RAW columns, so `CONCAT` yields NULL for -every NULL-organization row and the rows the `COALESCE(organization_id, -'__global__')` bucket exists to constrain are constrained by nothing (#5030's -shape) — indistinguishable by name from a healthy shadow. Skipping shadows -wholesale would have traded one false destructive finding for a true silent one; -that case is now reported as the ADR-0120 D4 tightening it is, runs the -duplicate pre-flight before anything is dropped, and is repaired by the apply. -A carrier whose expression cannot be read at all reports nothing rather than -proposing a drop it cannot reason about. diff --git a/.changeset/shadow-null-safe-key.md b/.changeset/shadow-null-safe-key.md deleted file mode 100644 index 5bff1b575a..0000000000 --- a/.changeset/shadow-null-safe-key.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -MySQL hash-shadow UNIQUE indexes (#11627) now hash the DECLARED key: the NULL-safe organization key part of an org-scoped unique (ADR-0120 D3) is embedded as `COALESCE(organization_id, '__global__')` inside the generation expression, so NULL-organization rows fold into the global bucket and collide with each other — the same key the direct index would have enforced. Previously the shadow hashed the raw columns, `CONCAT` returned NULL for every NULL-organization row, and a shadow-carried org-scoped unique silently enforced nothing on exactly the rows (single-tenant stacks, admin-global defaults) the NULL-safe key exists to constrain, while the boot log reported the constraint as carried. Plain composite shadows are unchanged: any-NULL tuples still conflict with nothing, matching MySQL's own composite-UNIQUE semantics. - -Deployment note — turning this constraint on is data-dependent: a MySQL database that accumulated duplicate NULL-organization rows while the shadow enforced nothing will fail the shadow `ALTER` with `ER_DUP_ENTRY` on its next boot. That failure is now diagnosed, not fatal: the boot continues, the log names the conflicting groups (probed over the same COALESCE key) and the operator action (`os migrate plan`, deduplicate, re-run), and the constraint is honestly reported as NOT enforced until the data is deduplicated — the same disposition as the direct NULL-safe route (ADR-0120 D4). Write-path duplicate diagnosis follows the key: a genuine NULL-organization duplicate is named in declared terms instead of being misreported as a hash collision. diff --git a/.changeset/share-door-declared-code.md b/.changeset/share-door-declared-code.md deleted file mode 100644 index d26453c8f0..0000000000 --- a/.changeset/share-door-declared-code.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the record-share family carries a demoted producer code on `declaredCode` (#12510) - -`GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now -put a producer's own error-code spelling on the wire's `error.declaredCode` when -the closed ADR-0112 vocabulary did not admit it. Previously that spelling was -resolved and then dropped at the re-dress: `respondSharingError` -(`packages/rest/src/rest-server.ts`) asks `classifiedRefusalAnswer` — the flat -`/data` door's own classification, which already carries the demoted string — -and forwarded only `status`, `code` and the message into the nested ADR-0112 D5 -envelope. - -Nothing invalid shipped, which is what made the loss silent and one-directional: -the closed `code` still carried the member the HTTP status derives, so every -body parsed, while an author's spelling vanished and a consumer told by ADR-0112 -to read `declaredCode` found nothing there. Measured before the repair, one -producer through both doors: a thrown `{ code: 'CLOSE_PERIOD_LOCKED', status: -409 }` answered `409 RESOURCE_CONFLICT` at both, with `declaredCode: -'CLOSE_PERIOD_LOCKED'` at `/data` and nothing at the share door. - -This ADOPTS the rule the sibling doors already apply rather than inventing one. -The demote is `demotedDeclaredCode`'s answer — the single definition of -"presence means demotion" — reached here through the classification's own -`declaredCode`, which the flat door computes with exactly that function -(`thrownCodeFields`, `packages/rest/src/error-response.ts`, #9232). The pair is -carried, not recomputed: this door asks the classification once and re-dresses -that one answer, as it already does for `status`, `code` and the message. - -Additive and shape-preserving. A REGISTERED producer code still carries no -`declaredCode` (repeating it would put two spellings of one fact on every -refusal), a producer that declared no code still carries none, a non-string -`code` is still context rather than a wire spelling, and `status`, `code` and -`message` are byte-identical to before on every existing path. The five-prefix -ADR-0111 idiom and the family's own `SHARES_LIST_FAILED` / -`SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` 500 terminal are untouched. -`ApiErrorSchema` has declared the field as optional since #9106, so the -contract's accept set does not move. diff --git a/.changeset/share-door-user-message.md b/.changeset/share-door-user-message.md deleted file mode 100644 index e04ed48f92..0000000000 --- a/.changeset/share-door-user-message.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the record-share family carries a producer-marked `userMessage` (#12669, fork (a)) - -`GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now -put a producer's own caller-facing refusal text on the wire's -`error.userMessage`. Previously that sentence was classified and then dropped at -the re-dress: `respondSharingError` (`packages/rest/src/rest-server.ts`) asks -`classifiedRefusalAnswer` — the flat `/data` door's own classification, which -attaches the mark in `withDeclaredUserMessage` (#9934) — and forwarded only -`status`, `code`, the message and, since #12510, `declaredCode` into the nested -ADR-0112 D5 envelope. - -Nothing invalid shipped, which is what made the loss silent and one-directional: -every body parsed as `ApiErrorSchema`, while a console told by ADR-0112 to render -`userMessage` verbatim found nothing at this door and fell back to its generic -substitution — for the same throw the twin door rendered. Measured before the -repair, one producer through both real routes: a thrown `{ code: -'CLOSE_PERIOD_LOCKED', status: 409, userMessage: 'Ask finance to reopen the -period.' }` answered `409 RESOURCE_CONFLICT` at both doors, with the sentence at -`/data` and nothing at the share door. - -The population is wider than its `declaredCode` neighbour's and the two are -deliberately not symmetric. `declaredCode` is read from the classification -because presence there MEANS demotion, an invariant a caller would otherwise -re-derive (#12510). `userMessage` has no such invariant: `declaredUserMessage` -already decided presence — a non-empty string, or nothing — and -`truncateClientMessage` already applied #5423's bound, so the classification's -own field is carried straight through. A REGISTERED code demotes nothing and so -carries no `declaredCode`, and still carries its author's sentence. - -Additive and shape-preserving. An unmarked refusal, an empty or whitespace-only -mark and a non-string one still carry no key; `status`, `code` and `message` are -byte-identical to before on every existing path; the five-prefix ADR-0111 idiom -and the family's own `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / -`SHARE_REVOKE_FAILED` 500 terminal are untouched. `ApiErrorSchema` has declared -the field as optional since #9934, so the contract's accept set does not move. - -Fork (b) of #12669 — mapping the flat dialect's top-level `issues` onto the -nested envelope's `ApiError.details` — is a shape decision on a contract field -and is deliberately not shipped here; #12669 stays open on it. diff --git a/.changeset/share-user-message-bypass-exits.md b/.changeset/share-user-message-bypass-exits.md deleted file mode 100644 index c307911147..0000000000 --- a/.changeset/share-user-message-bypass-exits.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the record-share family carries a producer-marked `userMessage` on its two non-classified exits (#12693) - -`respondSharingError` learned to carry the producer's caller-facing sentence at -its classified re-dress (#12669 fork (a)). The family has **two other exits** -that never reach that classification and still dropped it: - -- the **500 fault terminal** (`SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / - `SHARE_REVOKE_FAILED`) — `classifiedRefusalAnswer` deliberately hands a - declared or resolved 5xx back to "the catching route's own terminal", so a - marked fault never had a classification to ride; -- the **ADR-0111 message-prefix arm** — it runs precisely when the - classification answered nothing. - -Measured on `15bf9e859` before the repair, one marked producer per exit driven -through the real routes on both doors: - -```text -throw { code: 'SHARE_STORE_DOWN', status: 503, userMessage: '…' } - share door : 500 SHARES_LIST_FAILED — no mark - /data door : 503 SERVICE_UNAVAILABLE — mark carried -throw Error('NOT_FOUND: no such record …') + userMessage - share door : 404 NOT_FOUND — no mark - /data door : 500 INTERNAL_ERROR — mark carried -``` - -Nothing invalid shipped — every body parsed as `ApiErrorSchema` — which is what -made the loss silent and one-directional: a console told by ADR-0112 to render -`userMessage` verbatim found nothing at these two exits and fell back to its -generic substitution, for the same throw the twin door rendered. - -Neither exit holds a `refusal.body`, so the classified arm's expression is not -reusable at either. `error-response.ts` now exports -`boundedDeclaredUserMessage` — `declaredUserMessage`'s presence answer with -#5423's bound applied, lifted out of the private `withDeclaredUserMessage` -wrapper so a caller with no body to merge into can ask the same rule rather -than open-code it. The flat `/data` door is unchanged and goes on calling it -through that wrapper. - -⛔ Only the mark is added. Every existing key keeps its value and position at -both doors (measured: 32 route/door answers before and after, 0 statuses moved, -0 existing keys moved or changed, 24 gaining exactly `userMessage`). The three -deliberate share-vs-`/data` differences visible in the same measurement stay -exactly as they are — the family still folds a declared `503` into its own -`500`, still interpolates the caught message where `/data` withholds 5xx prose -per #5437, and `/data` is still not taught this service's local prefix idiom. - -No in-tree producer sets `userMessage` at this seam today -(`plugin-sharing` = 0 hits; positive control `throw ` = 25 files), so this -wires a declared channel rather than repairing a live loss. diff --git a/.changeset/shared-platform-row-org-resolver.md b/.changeset/shared-platform-row-org-resolver.md deleted file mode 100644 index 6d451ccaa6..0000000000 --- a/.changeset/shared-platform-row-org-resolver.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/plugin-approvals": patch -"@objectstack/service-automation": patch -"@objectstack/plugin-audit": patch ---- - -Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. - -- `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. -- `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. -- `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. -- `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. - -The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. diff --git a/.changeset/shared-tenancy-posture-hints.md b/.changeset/shared-tenancy-posture-hints.md deleted file mode 100644 index 240b445977..0000000000 --- a/.changeset/shared-tenancy-posture-hints.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -refactor(cli): single-source the tenancy posture hint table `os serve` and `os doctor` both print (#12492) - -`serve.ts` and `doctor.ts` each declared their own `TENANCY_POSTURE_FIX_HINTS`, -and the two tables were **byte-identical** — sha256 `97497ea8…` on both, modulo -the expression spelling the package name — under no cross-check of any kind. - -The `isolated` entry at least carried a package literal the spec-owned -`PLATFORM_PLUGIN_WIRED_RUNTIMES` roster could be pinned against (#12464 / PR -#12496). **`single` and `group` were the worse half**: they touch no roster, so -nothing anywhere could ever have noticed them drift apart. A reword of one -command's copy left the other describing the same posture differently to the -same operator, with every gate green. - -Both tables now come from one CLI-internal module, -`packages/cli/src/utils/tenancy-posture-hints.ts`, which also holds the single -`ORGANIZATIONS_RUNTIME_PKG` declaration. `Serve.ORGANIZATIONS_RUNTIME_PKG` -becomes a **re-export** of it rather than a second declaration, keeping the -stable handle `serve`'s boot path and the roster pin already address; the -module-local const #12464 added to `doctor.ts` is **deleted**, which is the -deletion condition that const's own docblock recorded against this card. The -literal is now declared twice (the roster key and this module) instead of three -times. - -Each command keeps its **own bullet assembly** — doctor renders -`• OS_TENANCY_POSTURE=

    ` inside a health-check fix list, serve renders -`• set OS_TENANCY_POSTURE=

    ` inside a FATAL refusal, at different -indents. Only the table was ever duplicated. - -The two sibling spelling pins are **retargeted, not dropped** — including the -roster leg, which is the load-bearing one (a rename of the package value leaves -the rendered leg green; only the roster leg catches it). Each gains one new leg -asserting that every posture bullet, `single` and `group` included, renders the -**shared** table's entry verbatim — so a command that re-grows a local copy goes -red instead of drifting in silence. - -**No behaviour change.** The rendered text is byte-identical before and after -for all three postures at both commands, verified by capturing both commands' -full rendered fix lists on `origin/main` and on this branch and diffing them. diff --git a/.changeset/sharing-options-logger-guaranteed-warn.md b/.changeset/sharing-options-logger-guaranteed-warn.md deleted file mode 100644 index 9755394a25..0000000000 --- a/.changeset/sharing-options-logger-guaranteed-warn.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/plugin-sharing": minor ---- - -⚠️ **Published-contract break for external hosts.** The `logger` option on the three -PUBLICLY EXPORTED options types — `SharingServiceOptions`, `ShareLinkServiceOptions` and -`SharingRuleServiceOptions` — now **requires** a `warn` channel, and its members carry real -signatures (`(msg: any, ...rest: any[]) => void`) instead of bare `Function`. A host that -constructs any of these services with `{ logger: { info, error } }` compiles today and stops -compiling after this release; add `warn` (or drop the `logger` option) to migrate. Nothing -about the services' runtime behaviour changes, and no call site inside this package moved: -the tightening was measured at ZERO compile errors within `plugin-sharing`, so the whole cost -falls on hosts, which is why it is declared here rather than shipped as a patch. - -Why: #9754 rules that a sink declaring an optional `error` must declare a NON-optional -`warn`, so a durability report always has somewhere to land — an optional `error` beside an -optional `warn` is a contract that permits silence. These three sinks were red against that -rule from the day it was written and were only invisible to its checker until #11069 taught it -to read bare `Function` as a channel. The maintainer ruled on 2026-08-24 (#10556) that they -tighten rather than stay baselined, shipped `minor` with the break named here. - -The bare-`Function` spelling was also its own defect: `Function` is not assignable to a -concrete signature, so `record-orphan-cleanup.ts` could not tighten its own `MinimalLogger` -while these producers stayed loose (#10692). That producer-side blocker is now clear. diff --git a/.changeset/sharing-sweep-logger-required-warn.md b/.changeset/sharing-sweep-logger-required-warn.md deleted file mode 100644 index ab277b00c3..0000000000 --- a/.changeset/sharing-sweep-logger-required-warn.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/plugin-sharing': minor ---- - -⚠️ **BREAKING (published parameter tightened):** `sweepOrphanedRowsByRecordExistence` — -publicly exported from `@objectstack/plugin-sharing` — now types its optional `logger` -parameter as `{ info?, warn }` with real signatures (`(msg: any, ...rest: any[]) => void`) -and a **required** `warn`, replacing the old `{ info?: Function, warn?: Function }`. A host -that passed this function a logger without a `warn` member (for example an -`{ info, error }`-only literal) compiles today and stops compiling after this release. The -one-line fix: add a `warn` callback to that logger object — or omit the `logger` argument -entirely, since the parameter itself stays optional. Nothing about the sweep's runtime -behaviour changes, and no call site in this repo moved: both in-package callers forward the -owning services' `logger` options, whose `warn` is already required since the producer -tightening (`SharingServiceOptions` / `ShareLinkServiceOptions` / -`SharingRuleServiceOptions`) shipped in the previous release. - -Why: every report this sweep emits lands on `warn` — the "could not check whether records -still exist", "stopped early" and "revoked N rows" lines — so a logger without a -guaranteed `warn` is one the sweep can lose its ONLY output into. That is #9754's -permit-silence shape, one module downstream of the producers #10556 tightened, and the -maintainer ruled (#10692, 2026-08-25) that this package's logger contracts refuse it -loudly at compile time rather than keep it. The bare `Function` members were also their -own defect: they documented no call shape and caught no arity mistake. The refusal is -pinned at compile time in `logger-required-warn.pin.ts`, which also pins the three -publicly exported options types above. - -Breaking ships as `minor` per the launch-window convention -(`scripts/check-changeset-no-major.mjs`). - - diff --git a/.changeset/sharing-write-denial-localized.md b/.changeset/sharing-write-denial-localized.md deleted file mode 100644 index 003d3a6aa1..0000000000 --- a/.changeset/sharing-write-denial-localized.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/plugin-sharing": minor ---- - -fix(plugin-sharing): the by-id write denial renders through the Operation -Message Catalog instead of a hardcoded English sentence (#12260, the consumer -half of the key #12493 landed) - -A user holding object-level allowRead + allowEdit — and no `modifyAllRecords` — -PATCHed a record they do not own on an object declaring -`sharingModel: 'public_read'` with `access: { default: 'private' }`. The sharing -middleware refused, correctly, and the client showed the server's reason -verbatim to the end user: one hardcoded English sentence naming the object's API -name and the row's opaque id. In a fully Chinese deployment that was the only -thing the user was told about why their save failed. - -The refusal now renders through the shared Operation Message Catalog in -`@objectstack/spec/system` under the key `record_write_denied` that #12493 -landed for it — the same mechanism `plugin-security`'s record-level denial -already uses, which is exactly the comparison the report drew: the same "I can -see this record but cannot change it" situation showed human language or raw -English depending on which layer refused. Same resolution ladder (deployment -override → the caller's locale → `en` → the key), same guarantee that a -misbehaving i18n service cannot turn a 403 into a 500. All four platform -locales (`en`, `zh-CN`, `ja-JP`, `es-ES`) ship copy that sends the reader to the -record's owner or an administrator instead of dead-ending them. - -`record_write_denied` is deliberately not `record_access_denied`: this gate -fires on a row the READ path already admitted, so "You do not have access to -this record" would be false the moment it rendered. It is one key for BOTH write -verbs — the user's situation and remedy are identical for `update` and `delete`. - -`buildSharingMiddleware` gains an optional third argument, a lazily resolved -`II18nService.t`-compatible lookup wired by `SharingServicePlugin`, because the -i18n service is contributed by another plugin and may start later. It is what -makes the override address the catalog documents, -`errors.record_write_denied`, take effect for this emitter. The argument is -additive: every existing caller passes two and is unchanged, and a stack with no -i18n service still renders the built-in catalog in the caller's locale. - -**Not changed: who may write.** The gate is byte-identical — ownership, write -depth, an edit-level share for `update`, Modify All Data — and the app-authored -RLS deferral ahead of it is untouched. The `FORBIDDEN:` prefix the REST layer -classifies 403 on is untouched, and so is the ADR-0111 D10 `delete`-verb -diagnostic breadcrumb. The verb, object and row id the old sentence carried are -now developer facts on the error's `developerMessage` and `details` and in the -log, where a developer reads them and a user never does. diff --git a/.changeset/signature-qrcode-bounded-text-family.md b/.changeset/signature-qrcode-bounded-text-family.md deleted file mode 100644 index 42ae919de5..0000000000 --- a/.changeset/signature-qrcode-bounded-text-family.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/driver-sql": minor ---- - -`signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: - -- **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. -- **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). -- **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. - -Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. diff --git a/.changeset/silver-eagles-shout.md b/.changeset/silver-eagles-shout.md deleted file mode 100644 index d67d8c1097..0000000000 --- a/.changeset/silver-eagles-shout.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os migrate plan` / `os migrate apply` exit when their work is done - -Measured on ObjectStack Cloud's staging control plane, inside `docker run --rm`: the CLI finished in 4.3 seconds and printed its own `Graceful shutdown complete`, and the run was cancelled by hand **78 minutes later** — the shell's next statement never ran, so it was still blocked on that one `docker run`. - -The composition these commands perform (#12938) registers a host's plugins for their DECLARATIONS: `init()` runs, `start()` is replaced with a no-op. Anything a host plugin arms during Phase 1 whose release would have been installed by Phase 2 — an interval, a pool, a watcher, a `kernel:ready` hook that starts a dispatcher — has no release path at all, so the event loop never drains while the kernel reports a clean shutdown. - -Both commands now end the process deliberately once their document is written, after the kernel teardown they already ran. Chasing the handle instead would mean auditing host code this repo cannot see, which is the same argument that made the composition declaration-only in the first place. `stdout` and `stderr` are drained before the exit, so a `--json` payload on a pipe is not truncated — and the drain itself is bounded, so a pipe whose reader has gone away cannot become a second way for the command not to return. - -Failure paths are unchanged: `this.exit(n)` throws an oclif `ExitError` that oclif's own handler already turns into a `process.exit`. diff --git a/.changeset/single-source-port-contract.md b/.changeset/single-source-port-contract.md deleted file mode 100644 index 18bced665f..0000000000 --- a/.changeset/single-source-port-contract.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os dev` / `os start` / `os serve` refuse an impossible port at their own door, from one shared contract - -The port range, the reader that turns operator text into a port, and the refusal -prose now live in a single module (`packages/cli/src/utils/port-contract.ts`) -that all three commands import. Before this, only `serve` validated: a value -typed at `dev` or `start` travelled to the spawned `serve` child and was refused -one process later, under the name of the CHANNEL it arrived on rather than the -spelling the operator had used. - -**FROM → TO — what changes, stated precisely.** - -- **The end-to-end accept set does not change.** Since #12662 every value listed - below already ended in a refusal; what moves is WHERE the refusal happens and - WHAT it names. Measured before and after by driving a table of 18 port texts - through all three real commands on all three channels (`--port`, `$PORT`, - `$OS_PORT`) — 162 runs per side, every row identical in verdict. Values that - boot today still boot, on the same port: ` 3000`, `3000 `, `+3000`, `08080`, - `3e3`, `0x0BB8`, `3000.0`, `3000abc` and `0b111` are all accepted, exactly as - before, and `parseInt`'s tolerance is deliberately preserved — a strict-decimal - reader would refuse six values that start a server today. -- **The refusal moves earlier: from the `serve` child to the parent's own door, - before anything is spawned and before any socket exists.** -- **The refusal names the operator's spelling.** - - `PORT=abc os dev` — FROM `✗ Invalid port: --port "abc"` TO - `✗ Invalid port: PORT="abc"`. - - `OS_PORT=abc os dev` — FROM `✗ Invalid port: --port "abc"` TO - `✗ Invalid port: OS_PORT="abc"`. - - `os start --port 99999` — FROM `✗ Invalid port: PORT="99999"` TO - `✗ Invalid port: --port "99999"`. - - `os serve` is unchanged in every respect; it already named what it could see. -- **`os dev --port ""` is unchanged: still dropped, not refused.** An empty - string is falsy, so `dev` forwards nothing and the child resolves its own - default — measured, and preserved deliberately, because refusing it would - narrow a published command's accept set. - -No new flag, no new environment variable, no new configuration key. The range is -declared in exactly one place in the repository; `os start`'s `--port` did NOT -gain `Flags.integer({ min, max })`, because that bound would be a second copy of -the range and, measured against `@oclif/core` 4.13.3, neither a flag's `parse` -nor an integer `min`/`max` runs over a value supplied by a flag's `default` — -which is how `$PORT` and `$OS_PORT` reach the CLI. diff --git a/.changeset/skill-catalog-projected-issue-ids.md b/.changeset/skill-catalog-projected-issue-ids.md deleted file mode 100644 index 18e69de4a5..0000000000 --- a/.changeset/skill-catalog-projected-issue-ids.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): strip the internal issue-id references that were projected into the published skill catalog - -The 2026-08-23 ruling stripped internal `#NNNN` citations from the published -skill corpus, but 14 of them were not authored in `skills/**` at all — they were -projected there from `.describe()` / TSDoc text in `packages/spec/src/**` by -`gen:skill-refs` and `gen:react-blocks`, so a hand-edit of the corpus could not -reach them and a regeneration would have put them straight back. - -Six source sites are rewritten to say the same thing without the citation, and -the artifacts are regenerated: the module summaries of `data/driver/common`, -`data/driver/config-registry`, `data/driver/turso`, `shared/retry-policy` and -`system/translation`, plus the `ListView.objectName` / `ListView.viewType` -deprecation notes and the `` summary in `ui/react-blocks`. The teaching in -each is kept, per the standing ruling of 2026-08-12, verbatim and untranslated: -「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. - -Customer-facing text changes in three places from the one source edit: the -published catalog (`skills/*/references/_index.md`, -`skills/objectstack-ui/references/react-blocks.md` and its sibling -`contracts/react-blocks.contract.json`), and the docs site -(`content/docs/references/data/driver-common.mdx`, `driver-turso.mdx`). No -schema shape, no `.describe()` used for validation, and no accept/reject -behaviour changes — the edits are comment and documentation text only. - -The doc-authoring gate's path exemption for the generated artifacts is removed -in the same change: it existed only because those files still carried projected -ids, and an exemption over a surface that no longer needs one is where the next -regeneration would smuggle one back in. diff --git a/.changeset/skill-examples-extraction-fence-aware.md b/.changeset/skill-examples-extraction-fence-aware.md deleted file mode 100644 index 82a6e173e2..0000000000 --- a/.changeset/skill-examples-extraction-fence-aware.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`check:skill-examples`: make the marked-block EXTRACTION loop fence-aware, not just the orphan scan - -The orphan-marker scan learned to ignore an `os:check` marker shown as example text inside a -wrapping fence, so this gate's own convention could be documented in the very roots it governs. -The extraction loop was deliberately left out of that fix, and kept recognising a bare ` ```ts ` / -` ```tsx ` / ` ```typescript ` fence-open line wherever it appeared, with no notion of sitting -inside another fence. A marker *alone* nested in an illustration was therefore handled correctly, -while a **fully worked** one — the marker AND a real ts fence, both written as example text inside -a wrapper — was extracted and handed to `tsc` as a genuine example: compiling by luck, or failing -the whole gate with a diagnostic pointing at documentation prose. - -Both loops now read one `fenceOwners()` walk. It records, per line, which top-level fence owns it, -so `owners[i] >= 0` answers the orphan scan's question ("is this marker inside a fence?") and -`owners[i] === i` answers extraction's ("does this ts fence open at top level?"). Because the -extraction loop's fence-open pattern is a strict subset of the CommonMark one the walk uses, a -genuine top-level block always owns itself and the new guard cannot suppress one. - -No occurrence in the corpus tripped this, so the counts are unchanged either side of the fix (257 -marked examples across 99 files, three surfaces). The self-test is where the defect is measurable: -a nested worked illustration whose payloads are deliberately uncompilable now extracts nothing, -while the identical payloads with the wrapper removed all extract — and the same pair is pinned -inside a JSDoc-gutter-wrapped docblock, where fence ownership has to be judged on the -gutter-stripped lines. diff --git a/.changeset/skill-refs-exports-fallback-ranking.md b/.changeset/skill-refs-exports-fallback-ranking.md deleted file mode 100644 index e7790c2366..0000000000 --- a/.changeset/skill-refs-exports-fallback-ranking.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): keep machine constants off the skill-reference `Exports:` fallback - -When a `.zod.ts` has no module doc block, `build-skill-references.ts` falls back -to listing its exports. That line is TRUE — an accurate list of what the module -exports, which is why #12094 kept the fallback rather than refusing. What was -wrong is the RANKING: the list was whichever five exports happened to be -DECLARED FIRST, and the extraction had no notion of authorable surface, so any -`export const` qualified — including constants whose own names say they are not -for authoring. - -Three of the eleven modules that reach this fallback declare their machine -constants near the top, so three published rows headlined them: - -- `automation/approval.zod.ts` named `DEPRECATED_APPROVER_TYPES`, - `NON_AUTHORABLE_APPROVER_TYPES`, `ORG_MEMBERSHIP_LEVELS` and - `APPROVER_EXPRESSION_ROOTS` — four of its five slots -- `kernel/plugin.zod.ts` named `CORE_PLUGIN_TYPES`, `CONSUMER_INSTALLABLE_TYPES` -- `system/translation.zod.ts` named `LEGACY_OBJECT_FIRST_KEYS` - -`skills/**` is loaded whole into a customer agent's context window and its job -is to teach that agent what it may author, so a row headlining -`DEPRECATED_APPROVER_TYPES` and `NON_AUTHORABLE_APPROVER_TYPES` pointed an -authoring agent at exactly the vocabulary it must not use, with nothing on the -line marking them as such. No gate could see it: `check:skill-refs` compares the -artifact against the generator, and the generator ranked faithfully. - -`SCREAMING_SNAKE` exports are now dropped and source order is kept for what -remains, with the cap of five applied AFTER filtering so the authorable names -waiting behind the constants are promoted rather than the row merely shortened. -A module whose entire export surface is machine constants falls through to no -description at all rather than printing a bare `Exports:`. - -Sorting `*Schema` exports first was considered and NOT taken: on the very row -that motivated this it demotes `ApproverType` — the approver-type enum an author -actually writes — below four schema objects, which is worse by this surface's -own standard. The rule moves to `scripts/lib/export-list.ts` so it can be pinned -without running the generator, and `scripts/export-list.test.ts` enforces it -both as unit cases and as a corpus gate over the checked-in artifacts. diff --git a/.changeset/skill-refs-module-docblock.md b/.changeset/skill-refs-module-docblock.md deleted file mode 100644 index a0bf6d02f6..0000000000 --- a/.changeset/skill-refs-module-docblock.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): publish each skill reference's own module doc block, not the first doc block in the file - -`build-skill-references.ts` derived every `_index.md` pointer description from -the first doc block anywhere in the source file. That is a rule about ORDERING, -not about descriptions: whichever declaration happened to sit nearest the top of -a `.zod.ts` donated its comment to a customer-facing page, and moving a helper -up a file silently rewrote published text. - -This is the defect the docs-site generator fixed by converging on -`findModuleDocBlock()` — the block must start at column 0, precede the first -declaration, and document no symbol. The skill-references generator was never -converted, so the two generators disagreed about the same sources. It now -imports the same selector rather than restating the rule, and `skills/**` is -loaded whole into customer agent context windows, so it was paying the higher -price for the same defect. - -Thirteen pointer rows across seven skills change. The live victim named on the -issue is `system/translation.zod.ts`, whose `objectstack-i18n` entry opened with -"Shared history sentence for every shape in this file." — the comment on a -private `TRANSLATION_HISTORY` string constant, meaningless to the reader and not -a description of the Translation protocol. Twelve rows in that class now fall -through to the existing `Exports: …` line, and one gains a real module -description (`shared/metadata-types.zod.ts`), which also brings it into -agreement with the docs page for the same file. - -Falling back rather than refusing is deliberate: an export list states a true -fact about the file, where the wrong block asserted a false one about its -subject. Whether a `.zod.ts` on this surface should be required to carry a -module doc block at all is a separate authoring question, left open here. diff --git a/.changeset/skill-zod-examples-drop-retired-trigger-phrases.md b/.changeset/skill-zod-examples-drop-retired-trigger-phrases.md deleted file mode 100644 index a7f93d1b80..0000000000 --- a/.changeset/skill-zod-examples-drop-retired-trigger-phrases.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** `skill.zod.ts`'s two `@example` blocks stop handing the author a retired key that throws on parse (#11026). - -Both TSDoc examples in `packages/spec/src/ai/skill.zod.ts` — the one over `SkillSchema` and the one over `defineSkill` — passed `triggerPhrases`, removed in `@objectstack/spec` 17.0.0 (#3896 audit close-out) and carried as a `retiredKey()` tombstone ~120 lines below the first of them. `defineSkill` calls `SkillSchema.parse()`, so both documented blocks **threw** when run: `invalid_type` at path `triggerPhrases`, expected `never`. A reader copying either block got a refusal on their first move. - -The blocks now demonstrate the tombstone's own prescription instead of contradicting it. That prescription is a **split**, not a rename — routing intent belongs in `triggerConditions` (an AND of context field/operator/value), natural-language intent in `description` / `instructions`, the strings actually put in front of the model — and the `defineSkill` block shows both halves with each named. `tools` is required with no default, so both blocks carry it. Each block also opens with its own `import { defineSkill } from '@objectstack/spec';`, matching the convention the marked SDK examples in `packages/client-react/src` already use, so the block is self-contained as a consumer would resolve it. - -The tombstone and every line of its guidance prose are **unchanged**, verbatim — they were always correct, and they are what the repaired examples now agree with. - -Prose only: no schema shape, no `.describe()` text, no runtime behaviour, no authorable-surface movement, and `check:generated` moves nothing (the generated reference page `content/docs/references/ai/skill.mdx` renders the tombstoned row as `[REMOVED]` and carries neither example). It is graded rather than skipped because the text ships to consumers: the rewritten blocks are present in the built `dist/skill.zod-*.d.ts`, which is what an editor surfaces on hover, and `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts` so the docblock also travels in the npm tarball as source. diff --git a/.changeset/smtp-port-contract-single-source.md b/.changeset/smtp-port-contract-single-source.md deleted file mode 100644 index deae144466..0000000000 --- a/.changeset/smtp-port-contract-single-source.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/service-settings": patch ---- - -The SMTP port range `1-65535` is now declared once and the refusal is generated from it (#12993). It had been hand-written three times across two packages: the enforcement in `SmtpTransport`, the `(expected 1-65535)` literal in the very next line's message, and `min: 1, max: 65535` on the mail settings form's `smtp_port` field — which `@objectstack/service-settings` really does enforce (`declaredBounds` / `validatePatch`), so it is a second door rather than decoration. The first two were adjacent lines, the cheapest possible drift: changing the check without changing the sentence yields a refusal that misstates its own rule, and nothing fails. - -`transports/smtp-port-contract.ts` now owns `SMTP_PORT_MIN` / `SMTP_PORT_MAX`, the predicate that applies them and the sentence that states them. The message text is **generated** rather than re-spelled, so the second spelling no longer exists to drift — the stronger of the two repairs the card named, since it deletes the drift instead of checking for it. - -Nothing is accepted or refused that was not before. The move is pinned as behaviour-preserving against the previous inline expression, kept verbatim as the oracle, over a table that includes both edges, the non-finite values and the non-integers. - -The settings manifest keeps its own numbers deliberately. `@objectstack/service-settings` does not depend on `@objectstack/plugin-email`, and the plugin depends on the service only as a test-only devDependency; making the manifest import the constant would add a runtime edge from a service to a plugin, invert the layering and pull `nodemailer` into the settings service's install closure. So the two are held equal by a cross-package assertion over that existing devDependency instead — the same mechanism that already holds the provider dropdown equal to `EMAIL_TRANSPORT_PROVIDERS` — and no new dependency edge is created in either direction. - -This range is **not** the CLI's listen range and must never be merged with it: `os serve` floors at `0` because port 0 asks the OS to choose one to listen on, while an SMTP port is a destination and floors at `1`. Collapsing them onto one constant would silently make `0` a legal SMTP port, so the floor is pinned explicitly. diff --git a/.changeset/spec-audit-meta-item-member.md b/.changeset/spec-audit-meta-item-member.md deleted file mode 100644 index 68d7059b62..0000000000 --- a/.changeset/spec-audit-meta-item-member.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**`MetadataProtocol` declares the optional `auditMetaItem` member, and the audit door's request/response schemas join the spec** (#11678 — the #11006 maintainer-ruled pattern, 2026-08-22 option B, carried one door over). - -`GET /api/v1/meta/:type/:name/audit` — the ADR-0010 §3.6 compliance trail behind Studio's 审计日志 / Audit log tab — was a step behind the half-declared publish door #11006 adjudicated: **neither** side was declared (`auditMetaItem` appeared nowhere in `packages/spec`), so the REST door reached the verb through `(p as any)` twice (feature-detection guard + call) and its request literal was compiled against nothing. - -Additive, not breaking: - -- `AuditMetaItemRequestSchema` / `AuditMetaItemRequest` — `{ type, name, organizationId?: string | null, limit? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `organizationId` is nullable because the REST door always sends it, possibly `null` (#8747's fail-closed tenant scoping: `null`/absent = env-wide rows only, never every tenant's). `limit` declares no bounds because the implementation clamps to [1, 500] rather than refusing. `environmentId` stays out by the #9741 ruling (transport-level routing key) — and on this door it is not even on the wire any more (#8747 removed it; the implementation never read it). -- `AuditMetaItemResponseSchema` / `AuditMetaItemResponse` — the `{ events: [...] }` body, newest first, with the closed `operation` (save/publish/rollback/delete/reset) and `outcome` (allowed/denied/forced) vocabularies and the ADR-0010 §3.3 `lockState`. The #9426 miss-vs-fault honesty is recorded in the declared types: `{ events: [] }` is the honest answer for a clean trail, a find-less host engine, or an unprovisioned audit table — never for a missing capability (501 before the call) and never for a failed read (propagated, not invented into an empty trail). -- `MetadataProtocol.auditMetaItem?(request: AuditMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. diff --git a/.changeset/spec-broken-sweep-predicate-is-a-filter.md b/.changeset/spec-broken-sweep-predicate-is-a-filter.md deleted file mode 100644 index 587d7a4d7b..0000000000 --- a/.changeset/spec-broken-sweep-predicate-is-a-filter.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): qualify the broken-sweep predicate in `FlowRunSummary` / `ConnectorActionEffect` TSDoc (#12722) - -Text only — no behaviour, no schema, no accept-set change. These doc blocks ship -in `.d.ts`, so a consumer reading the type got the sentence #12685 measurably -disproved. - -`selected > 0 AND acted = 0 AND unmeasured = 0` was stated as *the* broken-sweep -signal / query / alert across three doc blocks in two files. It is the FIRST -FILTER, not a verdict: a healthy idempotent sweep — re-select the same records, -gate each one on "already handled" — satisfies it on every run while that work -stands, so "over N consecutive runs" does not separate it from a dead gate -either (consecutiveness filters flapping, which is a different failure). What -discriminates is the per-node fold: a healthy skip is accounted for by a read -the run performed (`runs > 0` and `selected > 0` in `nodes[]`), while a dead -gate skips just as often with nothing behind it (`runs: 0`, or `selected: 0`). - -The wording mirrors what #12721 landed on `sys_automation_run`'s field -descriptions and `content/docs/automation/flows.mdx`, so the schema doc and the -object description now say the same thing — the point of the card, since the -schema doc is what a platform author actually reads. A corrected sentence in one -surface and a wrong one in the other is the state most likely to produce the -failure #12685 names: an operator wires an alert to the documented predicate, it -fires during normal operation, and it gets muted — leaving a dead sweep -unmonitored while looking monitored. - -The `unmeasured` clause's own point is unchanged, and now explicit: a run with -uncountable effects has an INCOMPLETE `acted` count, not a zero one. diff --git a/.changeset/spec-delete-meta-item-request-members.md b/.changeset/spec-delete-meta-item-request-members.md deleted file mode 100644 index 52ccf83083..0000000000 --- a/.changeset/spec-delete-meta-item-request-members.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**`DeleteMetaItemRequestSchema` declares the contract members the REST reset door sends** (#11679 — the #11006 maintainer-ruled pattern on the request-shape half). - -`MetadataProtocol.deleteMetaItem` was declared all along, but its request schema declared 2 of the 8 members `DELETE /api/v1/meta/:type/:name` sends — so the door's call site had to stay behind an `(p as any)` cast (removing it surfaced `TS2353` on six keys, the opposite half of the publish door's `TS2339`), and the one member most worth having a contract — `organizationId`, which selects WHICH overlay row a reset destroys (ADR-0005 org partition; an org-less delete reaches the environment-wide row) — was on the wire with no declaration behind it. - -Additive, not breaking — the five contract-level members join the schema, mirroring the implementation's parameter type in `@objectstack/metadata-protocol`: - -- `organizationId?` — tenant scope for the reset (#8805); load-bearing, decides which row the delete destroys. -- `parentVersion?` — the ADR-0008 optimistic-concurrency pin (REST: the `If-Match` header); absent = last-write-wins. -- `actor?` — identity recorded on the history tombstone row (one producer, #7749); absent = recorded actor-less, never "system" (#4556). -- `state?` — `'active' | 'draft'`; `draft` discards the pending draft overlay only. -- `dropStorage?` — destructive opt-in (default false): also drop the object's physical table (`object` + `active` only; never `sys_`). - -Two wire members stay out, by ruling rather than omission: `environmentId` (transport-level routing key per #9741, layered on by `packages/rest`'s `TransportScopedMetaRequest`) — and there are no internal coordination keys on this door (`_skipSeedApply` is publish-batch-only). diff --git a/.changeset/spec-dts-heap-ceiling-fits-container.md b/.changeset/spec-dts-heap-ceiling-fits-container.md deleted file mode 100644 index 5465ac7136..0000000000 --- a/.changeset/spec-dts-heap-ceiling-fits-container.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -build(spec): bound the DTS pass's heap ceiling to something the build container actually has (#12677) - -The DTS pass ran under `NODE_OPTIONS="--max-old-space-size=12288"`. A heap -ceiling is a promise to V8 that the memory exists: below it, V8 defers major GCs -and lets the resident set grow. A ceiling **above** the container's memory does -not permit a bigger build — it converts a recoverable JS heap error into a -kernel SIGKILL, because the process meets the container limit long before V8 -considers the ceiling reached, and `exit 137` carries no diagnostic. - -That is what took the docs site down: every `objectstack.ai` production deploy -since 2026-08-25 died with `@objectstack/spec:build` exit 137. Vercel builds the -docs app with `turbo run build --filter=@objectstack/docs` inside one -fixed-memory container, and this package is that app's only workspace -dependency — so this pass met the container limit alone. - -The ceiling is now `6144`. Measured on this package's DTS pass inside a cgroup -capped at 8192 MB, as peak anonymous RSS of the whole process tree: - -| ceiling | result | peak RSS | wall | -|---|---|---|---| -| 12288 (before) | ok, ~0.9 GB spare | 7290 MB | 132s | -| 6144 (after) | ok, ~2.3 GB spare | 5794 MB | 134s | -| 5120 | ok | 5328 MB | 148s | -| 4096 | `ERR_WORKER_OUT_OF_MEMORY` | — | 113s | - -No output change: every completing ceiling emitted a byte-identical declaration -tree (122 files, compared as one sha256 over all of them). The number buys -headroom and costs GC time, not build time. - -Should the live type graph ever outgrow `6144`, the pass now fails **loud** -with `ERR_WORKER_OUT_OF_MEMORY` instead of being killed silently. Raising the -number past what the build container has would trade that diagnostic back for -`exit 137`. diff --git a/.changeset/spec-flow-function-broken-sweep-filter.md b/.changeset/spec-flow-function-broken-sweep-filter.md deleted file mode 100644 index b98260c590..0000000000 --- a/.changeset/spec-flow-function-broken-sweep-filter.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `FlowFunctionEffectSchema`'s broken-sweep claim is a first FILTER, not the detector (#12685) - -`patch`, and not empty: these doc blocks ship in `.d.ts`, and this file's -`@module` block is emitted verbatim as the intro of -`content/docs/references/automation/flow-function.mdx` — so the sentence an -operator reads while wiring an alert is a published artifact of this package, -regenerated in this change. No schema, no accept set, no runtime behaviour -moves. - -## The wrong claim - -`packages/spec/src/automation/flow-function.zod.ts` stated -`selected > 0 AND acted = 0 AND unmeasured = 0` as *the* broken-sweep query, -unqualified — the third `packages/spec` surface carrying it, outside the fence -of the change that corrected `automation/execution.zod.ts` and -`integration/connector.zod.ts`. #12685 measured the A/B on one graph pair -through the real engine (pinned in `run-summary.test.ts`): a healthy idempotent -sweep — re-select the same records, gate each one on "already handled" — and a -dead gate BOTH report `selected > 0, acted 0, unmeasured 0`. "Over N -consecutive runs" does not rescue it: the healthy steady state is persistent -for as long as the outstanding work stands, so it trips on every run; -consecutiveness filters flapping, which is a different failure. - -The failure mode is silent. An operator who wires an alert to the documented -predicate watches it fire during normal operation and mutes it, leaving a dead -sweep unmonitored *while looking monitored*. - -## What the prose says now - -The predicate is stated as the FIRST FILTER and the per-node fold -(`FlowRunSummary.nodes[]` / `gates[]`) is named as the discriminator — the same -shape `sys_automation_run`'s field descriptions, `content/docs/automation/ -flows.mdx`, `automation/execution.zod.ts` and `integration/connector.zod.ts` -now agree on. Each clause keeps its own true point: a declared-`writes` -function makes `acted` INCOMPLETE rather than zero, which is why -`unmeasuredEffect` keeps the first filter off the flows that call one, and an -under-reported `selected` can still only make the filter quieter, never wrong. - -Five mentions in the file were triaged individually; the one that reads -"an under-report reads exactly like the broken sweep #4354 exists to detect" -was left standing, because it claims nothing about the three-clause predicate -and stays true. diff --git a/.changeset/spec-publish-command-spelling.md b/.changeset/spec-publish-command-spelling.md deleted file mode 100644 index c5d3b2b250..0000000000 --- a/.changeset/spec-publish-command-spelling.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Route the stored-envelope refusal to a command that exists — `os package publish`, not the retired `objectstack publish` (#12223) - -An author who hand-writes one of the seven `STORED_ENVELOPE_KEYS` onto an `api` -declaration is refused, and the refusal tells them where publication state actually -comes from. It named a command that resolves to nothing: - -```text -before: Remove it — publication state is managed by `objectstack publish`, not authored. -after: Remove it — publication state is managed by `os package publish`, not authored. -``` - -`os publish` was the legacy direct-to-environment command, retired with the path that -wrote `sys_environment_revision`. Re-measured on this tree against the **built oclif -`Config`** rather than against docs — loading the CLI's plugin and reading the command -table oclif derives from `dist/commands/**`: **61** ids, of which the only two containing -`publish` are `package publish` and `plugin publish`. There is no bare `publish` id and no -`publish` topic, so the old spelling exits as an unknown command. The message's own -neighbouring sentence already names `publishPackage` as the writer, and -`packages/cli/src/commands/package/publish.ts` is the command that runs it. - -This is the shape #12177 deliberately left alone elsewhere inverted: those sentences are -*about* the removal and are correct as history, while this one is **present tense and -prescriptive** — text an AI author obeys at the moment its write is refused. - -Text only. No accept/reject behaviour changes: the same seven keys are refused on the same -declarations, with the same `unrecognized_keys` upgrade path; only the sentence an author -reads is corrected. The same stale spelling is fixed in the `publisher` doc comment of -`packages/spec/src/cloud/package.zod.ts`, which ships to consumers in the package's type -declarations. diff --git a/.changeset/spec-publish-meta-item-member.md b/.changeset/spec-publish-meta-item-member.md deleted file mode 100644 index 73df48a459..0000000000 --- a/.changeset/spec-publish-meta-item-member.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**`MetadataProtocol` declares the optional `publishMetaItem` member, and `PublishMetaItemRequest` joins the spec** (#11006, maintainer ruling 2026-08-22, option B). - -`POST /api/v1/meta/:type/:name/publish` — the promotion half of Studio's designer save-then-publish loop — was a half-declared door: #7294 declared the response (`PublishMetaItemResponseSchema`) while the request shape and the interface member stayed undeclared, so the one HTTP call site reached the verb through a cast and its request literal was checked by nothing (measured: deleting the cast answers `TS2339` — the cast carried member-existence weight, not request-shape weight). - -Additive, not breaking: - -- `PublishMetaItemRequestSchema` / `PublishMetaItemRequest` — `{ type, name, organizationId?, actor?, message?, packageId? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `packageId` is `string | null` with the absent-vs-`null` distinction documented (absent = match any package; `null` = pin to the package-unbound row). `environmentId` stays out by the #9741 ruling (transport-level routing key; `packages/rest`'s `TransportScopedMetaRequest` layers it on), and the internal `_skipSeedApply` batch-coordination key is deliberately not part of the wire contract. -- `MetadataProtocol.publishMetaItem?(request: PublishMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. - -The follow-up that removes the `(p as any)` cast at the REST call site rides the engine lane; this change only declares the contract. diff --git a/.changeset/spec-refusal-message-issue-ids.md b/.changeset/spec-refusal-message-issue-ids.md deleted file mode 100644 index 3d4159315e..0000000000 --- a/.changeset/spec-refusal-message-issue-ids.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Strip internal tracker ids from the refusal messages an author actually reads (#12124) - -Fifteen zod refusal messages across nine `packages/spec/src` files ended a sentence with -an internal issue id. Those strings are printed **at** the author, verbatim, the moment -their metadata is refused — by `os validate`, by a publish gate, by a parse — and the -reader has no tracker to open. A `#NNNN` there is a citation-shaped token that resolves to -nothing, in the one place the sentence most needs to be actionable. - -```text -before: A field condition's keys are field names, never $-prefixed operators (#7711). -after: A field condition's keys are field names, never $-prefixed operators. - -before: ... refused at authoring time because the query path refuses it too - (400 INVALID_FILTER, #5869). -after: ... refused at authoring time because the query path refuses it too - (400 INVALID_FILTER). -``` - -Where a customer-resolvable anchor already carried the meaning it was kept and the id -dropped beside it: the second example above keeps `400 INVALID_FILTER`, which is the token -an author can actually match their query-path error against. Where the reference is -load-bearing for an *internal* reader only, it moved to an adjacent `//` comment (four -sites: the endpoint publish gate's two `#5040` section pointers, the summary-field rule's -founding incident, and the interim renderer precedence behind the doubled-redirect -refusal). Elsewhere it is simply gone — git history keeps the anchor. - -Text only. **No accept/reject behaviour changes**: the same inputs are refused on the same -schemas with the same issue `code`, `path` and error shape; only the sentence changes. -Test twins that pinned the old wording now pin the new text plus a negative assertion that -the message carries no issue id at all. - -The convention is held mechanically from here — `check:doc-authoring` gained a third rule -that parses `packages/spec/src` and reds on an id in any refusal-message string. It parses -rather than scanning lines because refusal prose here is written as multi-line string -concatenation: a single-line `message:.*#[0-9]{3,5}` grep sees 1 of the 16 literals. diff --git a/.changeset/spec-routes-doc-redirect-http-server.md b/.changeset/spec-routes-doc-redirect-http-server.md deleted file mode 100644 index 7bf280a97c..0000000000 --- a/.changeset/spec-routes-doc-redirect-http-server.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): stop recommending `contributes.routes` for code-handler endpoints — redirect the author-facing materials to the imperative `http.server` mount (#10726) - -`contributes.routes` (`packages/spec/src/kernel/manifest.zod.ts`) has **zero readers** -monorepo-wide: the only non-test read of `manifest.contributes` anywhere reads `kinds`, not -`routes` (`packages/objectql/src/engine.ts:4499`). An author following the shipped guidance -writes a `contributes.routes` entry, gets a clean parse, and serves nothing — ADR-0049's -silent no-op with a published recommendation attached. - -Per the maintainer ruling (2026-08-22, Option B), the four author-facing materials that -recommended the key are corrected **now**, ahead of and independent of the key's removal: - -- `skills/objectstack-api/SKILL.md` — the `apis:` decision table's second row now names the - imperative `http.server` mount and states explicitly that `contributes.routes` parses and - serves nothing. -- `packages/spec/src/api/dispatcher.zod.ts` — the HttpDispatcher protocol doc no longer - claims it "supports dynamic route registration from plugins via contributes.routes". -- `docs/adr/0088-metadata-kind-admission-and-retirement.md` — the `router` retirement row no - longer credits `contributes.routes` as a delivered form. The `router` KIND's retirement is - unaffected: its delivered forms are the imperative mount and, since #5040, declarative - `apis:`. -- `packages/spec/src/ui/app.zod.ts` — the `App.apis` removal message no longer sends - migrators to `contributes.routes`. - -The replacement recommendation was verified live on `main` before it was written, so this is -not a redirect to a second dead form: `http.server` is registered by -`packages/plugins/plugin-hono-server/src/hono-plugin.ts:271` (`providesServices` at :228) and -mounted by real in-tree consumers — `examples/app-showcase/src/system/server/recalc-endpoint.ts` -resolves it on `kernel:ready` and mounts `POST /api/v1/showcase/recalc`, and -`plugin-approvals`, `plugin-sharing`, `cloud-connection` and the CLI's `serve` all resolve the -same service. - -Schema-only change to prose: no key is added, removed or re-typed here. The -`contributes.routes` tombstone itself is #10724's, which is blocked on the `cloud` census -(#10812) — this changeset carries only the doc corrections the ruling ordered not to wait. -The two `content/docs/references/**` pages are the regenerated projection of the two `.zod.ts` -edits (`pnpm --filter @objectstack/spec gen:docs`), not hand edits. diff --git a/.changeset/spec-src-tsdoc-example-surface.md b/.changeset/spec-src-tsdoc-example-surface.md deleted file mode 100644 index 4db678491a..0000000000 --- a/.changeset/spec-src-tsdoc-example-surface.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Type-check the TSDoc code examples in `packages/spec/src` — the ADR-0033 authoring channel - -`check:skill-examples` now scans a third surface: `packages/spec/src/**` TSDoc blocks, -alongside `skills/` + `content/docs` and the client SDK sources. A schema's `@example` -is what an AI author copies, and it sits inches from the tombstone written for that same -reader — but nothing compiled it, so an example could name a retired key, a renamed -export or a tightened union and stay green indefinitely. (`check:doc-formula-expressions` -walks the same blocks but judges *formula expressions*, never TypeScript.) - -This is a new `SURFACES` entry, not a second extractor: the existing marker/tsc pipeline -was already surface-parameterised. Compilation stays **opt-in** via the `os:check` -marker, which matters more on this root than anywhere else — of its 146 fenced ts blocks, -128 carry no imports of their own and three more are ellipsis-placeholder prose -(`defineStack({ ... })`) that is correct as documentation and can never compile. Six -self-contained blocks are marked and now compile against the built declarations. - -The marker is an inert HTML comment, as on the other surfaces, and is stripped from -generated reference pages rather than published to them. diff --git a/.changeset/sql-driver-field-string-comment-fix.md b/.changeset/sql-driver-field-string-comment-fix.md deleted file mode 100644 index 33bc44ee48..0000000000 --- a/.changeset/sql-driver-field-string-comment-fix.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): correct two comments in `createColumn` that cited a `Field.string` builder that has never existed (#12593) - -Two comments in the keyed/bounded text-family branch of `createColumn` -(`signature` / `qrcode` / `richtext` / `code`) asserted a `Field.string` -builder exists and that it "has always taken knex's `varchar(255)`." It does -not exist and never has: `Field` has 37 keys and none is `string`, -`FieldType.options` (49 entries) does not list it, and -`FieldSchema.safeParse({ type: 'string', … })` fails at `[type]` — all three -reproduced fresh on this branch, plus `git log -S` confirming no commit ever -added such a builder key to `field.zod.ts`. - -The name is not arbitrary: `'string'` is knex's own column-builder method -name (`table.string(name)`), reused internally by this driver as its -*untyped* default (`field?.type || 'string'`) — a storage-side spelling that -collides with, but is not, an authoring-side one. The corrected comments now -state only checkable facts: knex's bare `table.string(name)` (no length) is -`varchar(255)`; this branch never calls it bare, it calls -`table.string(name, keyable)` with the field's own declared `maxLength` -(up to `MAX_KEYABLE_VARCHAR_CHARS`, 768 chars); and the nearest *authorable* -spelling that reaches this exact `varchar(n)` shape is `Field.text({ -maxLength: n })` on a keyed column — exactly what this switch arm already -serves. - -No code changed — `git diff` is comment-only lines inside `sql-driver.ts`. -Nothing here alters DDL, column widths, or any runtime behavior; the two -pins that already exercise this exact branch behaviorally -(`sql-driver-11565-row-byte-budget.test.ts`'s "agrees with createColumn about -every FieldType" mirror, and `sql-driver-keyed-text-mysql.test.ts`'s -"emits varchar(maxLength) for a keyed bounded field") both stay green, -unmodified. - -**Grade: `patch`, and deliberately no higher.** This is documentation -embedded in source, not an exported symbol, a spec key, or any authorable or -runtime surface — there is nothing here for a consumer to migrate. `patch` -is the correct floor for a fix that changes only what the driver's own -source *says*, matching the sibling `builtin-column-delivery-id-type.md` -changeset (#12131) that corrected the same false `Field.string` premise in -an adjacent file. Not a declared-breaking changeset, so no ADR-0087 -disposition marker applies. diff --git a/.changeset/sql-emission-identity-one-source.md b/.changeset/sql-emission-identity-one-source.md deleted file mode 100644 index 55e93ea10a..0000000000 --- a/.changeset/sql-emission-identity-one-source.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/spec': minor ---- - -feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) - -**BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` -under the repo's launch-window convention for breaking changes — and a widening -in the same edit, so read both directions. - -Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative -归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol -without being the PostgreSQL this driver emits DDL for, and the driver had no -opinion about any of them — it simply let knex compile whatever it compiles. -Measured on `origin/main` before the change, one `CREATE TABLE` per client: - -``` -pg / pgnative / cockroachdb "body" text primary key inline -redshift "body" varchar(max) primary key in a separate ALTER TABLE -``` - -So on Redshift the pre-ruling behaviour was not a failure — it was a table of a -different shape, built quietly, with the deployment finding out when it wrote -data into it. - -**Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches -schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, -`reconcileManagedSchema` — now gets an immediate -`UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` -(newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), -HTTP status `501`, and a message naming the client, every client the driver -DOES emit for, and the supported way to keep the database — manage its schema -out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws -before any statement is issued, so nothing is half-built. Connection, the -connect bound and the #11389 calendar-day parser are untouched: the boundary is -DDL only, drawn where behaviour was actually verified. - -**Recognised (widening).** `pgnative` is now a member of the Postgres emission -family — knex resolves it to the same `postgresql` dialect and the same query -compiler as `pg`, differing only in which npm binding carries the bytes. It was -previously in neither the emission set nor the wire table, so a `date` column -got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact -#11550 defect) and no calendar-day parser. It now behaves identically to `pg` -and carries the #11389 pin. - -**One source of truth.** The pair `cockroachdb, redshift` used to be -hand-written into the connect-timeout table and again into the wire table. It -is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend -the emission sets through it — as does the refusal, which reads the same set. -Adding a future pg-wire client is one edit, and the three answers cannot drift -apart. `mariadb` is explicitly out of the ruling's scope and keeps its third -state: neither recognised nor refused. - - diff --git a/.changeset/sqlite-audit-column-canonical-default.md b/.changeset/sqlite-audit-column-canonical-default.md deleted file mode 100644 index 021619067b..0000000000 --- a/.changeset/sqlite-audit-column-canonical-default.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Fix:** on SQLite the builtin `created_at`/`updated_at` audit columns now take the same canonical ISO-8601 `DEFAULT` a declared `Field.datetime` NOW() column in the same table already gets (#11321). - -`createAuditTimestampColumn`'s non-MySQL branch was `table.timestamp(name).defaultTo(this.knex.fn.now())`. On SQLite `knex.fn.now()` compiles to an unqualified `CURRENT_TIMESTAMP`, which renders a zone-**naive**, space-separated, second-precision `'YYYY-MM-DD HH:MM:SS'`. A declared `defaultValue: 'NOW()'` field in the **same table** already got `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))` from `nowColumnDefault`, so one table carried two spellings of one conceptual value: - -``` -created_at "2026-08-23 14:54:17" <- builtin audit (naive) -when "2026-08-23T14:54:17.796Z" <- declared field (canonical) -``` - -That naive spelling is the one `updatedAtStamp()`'s own docblock condemns: `Date.parse` reads a zone-less string as LOCAL time, silently shifting the instant by the host offset on a non-UTC runtime. It is also the pre-canonical storage form `backfillCanonicalDatetimes` exists to converge — reached here by a path writing it *today*, not by legacy data. - -The SQLite branch is now routed through `nowColumnDefault('datetime')` — the existing single source for "what does NOW() mean in DDL on this dialect" — rather than restating the expression, so the two cannot drift apart again. **Postgres and MySQL are untouched**: `knex.fn.now()` on Postgres is a real zone-aware `TIMESTAMP` that never had the ambiguity, and MySQL keeps the `now(3)` precision match from #11224. - -`rebuildSqliteTablePatched` — the whole-table rebuild SQLite drift reconciliation uses — re-emitted the audit default itself as `knex.fn.now()`. That method is SQLite-only, so leaving it would have silently **reverted** a canonically-created table the moment any unrelated drift (a relaxed NOT NULL, an orphaned column) triggered a rebuild. Fixed in the same change: a rebuild hands back the column `initObjects` would have built. - -**Graded `patch`, not `minor`, on a measurement rather than a judgement.** The change alters emitted DDL, so the question that decides the grade is what it does to databases that already exist: - -- **Existing tables are not altered.** Both call sites are `CREATE TABLE` only; `initObjects`' `alterTable` branch adds declared fields and never the audit columns. A table already on disk keeps `default CURRENT_TIMESTAMP`. -- **They do not start reporting drift.** Measured on live in-memory SQLite through the real `detectManagedDrift` entry point against a table carrying the old default: **zero** entries. Two independent guards — `BUILTIN_COLUMNS` skips `created_at`/`updated_at` in both of `diffManagedTable`'s loops, and the only `default_mismatch` producer is the #4560 runtime-token check, for which `isAppResolvedDefaultToken('NOW()')` is pinned `false`. The measurement carries a positive control: in the same call on the same table, drift reports `unmapped_column` **and** `default_mismatch` for a `current_user` column, so the default-reading dimension is demonstrably live and still says nothing about the audit columns. -- **Rows already written naive keep reading correctly.** `formatOutput`'s `repairNaiveUtcAuditTimestamp` folds them to canonical on read — the same disposition `nowColumnDefault` already documents for declared fields. - -So no deployment changes behaviour on upgrade; only newly-created tables get the corrected default. - -The population this actually repairs is wider than "writes that bypass the driver". `stampInsertTimestamps` fills both columns app-side, but it gates on `tablesWithTimestamps`, which only DDL-running paths populate. On the documented `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` posture, `registerObjectMetadata` (the DDL-free registration door) deliberately does not touch that set — so the set is empty, the stamp returns early, and **the driver's own `create()` door reaches the column DEFAULT**. Measured, one table, one row per boot posture: `created_at "2026-08-23T14:54:17.791Z"` on a normal boot versus `"2026-08-23 14:54:17"` on a `skipSchemaSync` boot, with the declared NOW() sibling canonical in both — because its canonical shape lives in the column DEFAULT rather than in an app-side stamp. That asymmetry is the argument for fixing this in DDL, and it is now closed. diff --git a/.changeset/sqlite-json-injective-codec.md b/.changeset/sqlite-json-injective-codec.md deleted file mode 100644 index 1a44e740c3..0000000000 --- a/.changeset/sqlite-json-injective-codec.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql): make the SQLite `Field.json` codec injective — one encoding across all three dialects (#12380) - -**BREAKING** storage-format change for `Field.json` columns on SQLite (and the -SQLite-backed `driver-turso` / `driver-sqlite-wasm`, which inherit this codec), -shipped as `minor` under the repo's launch-window convention for breaking -changes. Postgres and MySQL are **untouched** — this makes SQLite match what -they have always done. - -`formatInput` now `JSON.stringify`s every `Field.json` value on every dialect, -and `formatOutput` parses it back. That **deletes a dialect branch rather than -adding one**. - -## What was wrong - -Measured 2026-08-26 through the driver boundary on live SQLite, live Postgres -16.13 and live MySQL 8.0.46, with each stored cell read back through a separate -raw catalog query: **Postgres and MySQL were 17/17 faithful; SQLite was 13/17 -type-changed.** Three independent mechanisms, only two of them reversible: - -1. **Read-side.** `formatOutput` `JSON.parse`s every string in a json column, so - a stored string whose *content* is valid JSON came back type-changed — - `'true'` → boolean, `'null'` → null, `'[]'` → array, `'{"a":1}'` → object. -2. **Write-side.** The column is declared type `json`, which contains none of - `INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/`FLOA`/`DOUB`, so SQLite's affinity - rules fall through to **NUMERIC** and a bound number-like string was converted - to INTEGER/REAL *before storage*: `'123'`, `' 123 '`, `'0123'`, `'1e5'`, - `'1.0'`, `'-0'` were destroyed on disk. ⛔ Not reversible. -3. **Native booleans.** `true` was stored as INTEGER 1 and read back as the - number `1` — `formatOutput`'s `booleanFields` pass is keyed to declared - `Field.boolean` *columns*, not to booleans inside a json payload. - -The contract decides which dialect is right, not strictness: `json`'s stored -contract is `z.unknown()` because *"openness is now an explicit decision, not an -accident of nobody checking"* (`packages/spec/src/data/field-value.zod.ts`). An -explicitly-open contract admits both `123` and `'123'` as legal values of one -field, so no driver may collapse them onto one representation. - -The live consumer is `sys_setting.value`, which is `Field.json`, and the settings -service persists verbatim and reads back with no re-coercion by declared type — -so the driver's answer is what the caller gets, on the dialect tenant -environments actually run. - -## What changes on disk, and what does not - -The DDL is unchanged — the column is still declared `json`, so NUMERIC affinity -is still in force. The encoded form defeats it because a string's encoding -carries its quotes (`'123'` → `"123"`, which is not a numeric literal). Pinned -live rather than reasoned. - -For **new** writes the on-disk delta is exactly two classes: - -- **strings** are now quoted JSON text; -- **booleans** are now TEXT `true`/`false` instead of INTEGER `1`/`0`. - -Objects, arrays, `null` and **numbers** are byte-identical to before (`123` bound -as a number and `"123"` bound as text both land as INTEGER `123`). - -⚠️ An out-of-band reader of a SQLite file — anything reading the table with its -own SQL rather than through this driver — now sees quoted JSON text where it saw -a bare value. - -## The migration, and the limits of what it can recover - -`backfillCanonicalJsonEncoding` runs on `syncSchema`/`initObjects` for existing -tables, the same posture and shape as the `backfillCanonicalDatetimes` and -`backfillCanonicalTimes` storage-format migrations beside it: one `UPDATE` per -column, failures logged and swallowed, correctness never contingent on it having -run. It converts the **one on-disk class the pre-fix encoding left unambiguous** — -a TEXT cell that is not valid JSON, which nothing but a stored plain string could -have produced — into its quoted form. Idempotent by construction: the `WHERE` is -the exact complement of the `SET`'s output, so a converted row cannot match again -and re-running costs one scan and zero writes. - -⛔ **It does not guess, because the rest cannot be guessed**, and two classes are -therefore left exactly as they are: - -- **INTEGER/REAL cells.** A number, a boolean, and a number-like string eaten by - NUMERIC affinity are the *same bytes* on disk — `123` the number and `'123'` - the string are one INTEGER `123`. No migration can know which was written. -- **TEXT cells that already parse.** A stored object `{"a":1}` and a stored - *string* `'{"a":1}'` were byte-identical before this change. Re-quoting them - would turn every legacy object and array into a string — corrupting the common - case to guess at the rare one. - -⇒ Those rows read after this change exactly as they read before it. **The class -stops growing; it is not retroactively repaired.** Maintainer ruling 2026-08-26, -with that cost accepted explicitly. - -The migration changes **no read**: a legacy plain string reads back as that -string before it runs (via `formatOutput`'s parse fallback, kept for exactly this -reason and now documented as the pre-#12380 read-side repair) and after it runs. -It is a canonicalisation that makes the on-disk format uniform and injective -going forward, not a repair of something that reads wrong today. - -## What upgraders may notice - -Values that were being **corrupted** now read back correctly. Code that adapted -to the corruption is what changes underneath: a boolean `Field.json` value that -read back as `1` now reads back as `true`, and a string whose content is valid -JSON now reads back as that string instead of the structure it looked like. -Filters are unaffected — every scalar comparison operator on a json column is -already refused by the driver (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), so no -predicate could have been keyed to the old stored text. - - diff --git a/.changeset/sqlite-primary-key-not-unique-introspection.md b/.changeset/sqlite-primary-key-not-unique-introspection.md deleted file mode 100644 index 155ee97980..0000000000 --- a/.changeset/sqlite-primary-key-not-unique-introspection.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`introspectUniqueConstraints` no longer reports a PRIMARY KEY column as unique on SQLite, so all three dialects now answer the same question (#11654). The SQLite arm read `PRAGMA index_list` keyed only on `unique === 1`, and SQLite materialises a non-INTEGER primary key as a unique auto-index — so a `varchar` key was reported while the Postgres and MySQL arms, which filter on `CONSTRAINT_TYPE = 'UNIQUE'`, never see a primary key at all. It also disagreed with itself: an `INTEGER PRIMARY KEY` is a rowid alias with no auto-index, so the same logical schema produced a different `isUnique` flag depending only on the declared type of its key. The arm now skips `origin: 'pk'` index rows, which closes both gaps at once (`WITHOUT ROWID` keys included). - -This continues #11202's convention: `isUnique` means a *declared single-column UNIQUE constraint*. Nothing is lost — primary-key membership is still reported losslessly through `IntrospectedTable.primaryKeys` and `IntrospectedColumn.primaryKey`. The filter is on the index's `origin`, not on whether the column is in the key, so a key column that separately carries its own unique index stays flagged. - -Consumer-visible effect: `introspectedSchemaToObjects` in `@objectstack/objectql` turns this flag into a drafted field's `unique: true`, so a federated-object draft (ADR-0015) taken from a SQLite table no longer gains a redundant `unique: true` on its key column that the same table drafted through Postgres or MySQL never had. Drivers extending `SqlDriver` (`driver-turso`, `driver-sqlite-wasm`) inherit the change. diff --git a/.changeset/sqlite-rebuild-applied-honesty.md b/.changeset/sqlite-rebuild-applied-honesty.md deleted file mode 100644 index 06a2040c3d..0000000000 --- a/.changeset/sqlite-rebuild-applied-honesty.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Fix:** on SQLite, `applyMigrationEntries` no longer reports an op as **applied** just because a table rebuild ran (#11722). - -`SqlDriver.applyMigrationEntries` splits by dialect, and the two arms disagreed about what `applied` means. The in-place arm (Postgres / MySQL) asks per entry and believes the answer — `applyDriftOpInPlace` returns `false` for an op its dialect cannot perform, and the entry goes to `skipped`. The SQLite arm did not ask at all: it called `rebuildSqliteTablePatched(table, ents)` and then `applied.push(...ents)`, every entry, unconditionally. But that rebuild honours exactly four op types — `relax_not_null`, `tighten_not_null`, `drop_column`, `drop_column_default` — and silently ignores everything else; its own docblock already said so for the varchar ops. An ignored op was still reported applied. - -**The failure mode is a false green, not an error.** Nothing throws and nothing is skipped, so every consumer announces work that never happened: `reconcileAndWarnDrift` logs `auto-reconciled on `, and the artifact boot gate prints `↪ migrated `. The finding is still physically present, so the next boot detects it again, reports drift again, and "migrates" it again — a loop with no failing signal anywhere in it. - -**What changes.** `rebuildSqliteTablePatched` now returns the entries it actually acted on, built in the same pass that fills the four column sets it already partitioned into — deliberately not a second list of op types to keep in sync, so the returned set cannot drift from the work done. The caller reports those as `applied` and routes the remainder to `skipped`, logging it in the **same sentence** the in-place arm uses for an op its dialect cannot do (` on
    . is unsupported on dialect 'sqlite' — skipped`), so one greppable line covers all three dialects. `@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso` extend `SqlDriver` without overriding either method, so both inherit the correction. - -**What deliberately does not change.** No op does anything different — this moves only what is *reported*. In particular the rebuild still runs for the whole table even when it honours nothing: it re-materializes every kept column's default (#11321, #4560) and the full declared index set from metadata (#3696), so it is not a no-op, and suppressing it would change what the reconciler DOES rather than what it says it did. `applied`/`skipped` remains a reported partition consumed by log lines and CLI counts; it is not an accept/reject door, and no public surface widens. - -**Latent when found, and fixed anyway.** The gap was unreachable at the time of the fix, held closed from two independent directions neither of which knew it was holding it: `enforcesVarcharLength` excludes SQLite, so the differ never emits `widen_varchar`/`narrow_varchar` there, and `multiValueColumnTypeIsLoadBearing` excludes SQLite for an unrelated measured reason, so #11535's `manual_column_type_change` is never emitted there either. The next column op that is not SQLite-rebuildable would have opened it silently. `manual_column_type_change`'s own docblock states that `applyMigrationEntries` reports it "skipped, never applied" — measured on Postgres and MySQL; that sentence is now also true on SQLite, and the docblock says so. - -Pinned by `packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts`, which constructs the reachability rather than waiting for it — it substitutes only the differ's dialect guard, handing entries straight to the public `applyMigrationEntries` seam that `os migrate apply` and the artifact boot gate both call, with a real driver, dialect and database throughout. All five cases fail on the pre-fix tree, including the consumer-level one that catches `auto-reconciled` being logged for an op that never happened. diff --git a/.changeset/stale-fill-i18n-leaf-repair.md b/.changeset/stale-fill-i18n-leaf-repair.md deleted file mode 100644 index 856df7b691..0000000000 --- a/.changeset/stale-fill-i18n-leaf-repair.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/plugin-webhooks": patch -"@objectstack/service-messaging": patch ---- - -fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) - -`os i18n extract` merges gaps only, so a revised source string leaves the previous -revision standing in every translated locale — in sync by key, green under -`check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves -`check:i18n-stale-fill` froze in its baseline are re-translated here from the -**current** `en` source, and the baseline is ratcheted to empty in the same change. - -User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: - -- `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a - `"certified"` governance flag that was removed from the declaration in 16.0. -- `sys_webhook.fields.method.help` — all three locales served the pre-revision method - enumeration after the source became a prose description. -- `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now - Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose - uses and which carries no plural inflection. -- `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and - `zh-CN` a translation of the same superseded source; all three now carry the - PARKED / terminal-row clause the source documents. -- `sys_notification_subscription.fields.principal.help` — the selector list was missing - the `owner_of:object:id` and bare-email forms in all three locales. - -No schema, export or runtime behaviour changes: translated-locale leaf values only, -plus the shrink-only ratchet baseline. diff --git a/.changeset/stamp-organization-on-notification-writes.md b/.changeset/stamp-organization-on-notification-writes.md deleted file mode 100644 index 158a2d3263..0000000000 --- a/.changeset/stamp-organization-on-notification-writes.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/service-automation': patch -'@objectstack/service-messaging': patch ---- - -Stamp `organization_id` on flow-produced notifications and on `markRead` -receipts, so the notification family stops writing org-less rows - -An application project's read-only inventory found `sys_inbox_message`, -`sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` -carrying `organization_id = NULL` on **100%** of their rows — existing rows and -same-day new ones alike, while `sys_approval_request` in the same database -carried an organization on every row. Ruled a gap, not a design choice. - -Everything below the messaging ingress was already threaded: `emit()` stamps the -`sys_notification` event, the inbox channel stamps `sys_inbox_message` and its -`delivered` receipt, and the outbox carries the value onto -`sys_notification_delivery`. Each of them reads `EmitInput.organizationId` — -and the `notify` flow node, the dominant producer, never supplied it. Its local -structural mirror of `emit()` did not even declare the field, so the value could -not have been passed. One missing argument, four tables at 100% null. - -The node now threads the organization from the run's own acting context -(`AutomationContext.tenantId`), the same source the `collab.mention` producer in -`@objectstack/plugin-audit` already uses, so the two notification producers agree -about whose organization a notification carries. - -A second producer of the same table is fixed alongside it: the `read` receipt -`markRead` inserts — written when a user reads a notification whose delivered -receipt never landed — named no organization at all. It now carries the -organization of the `sys_notification` row it is about. - -There is deliberately **no fallback limb** in either producer: not "the current -organization", not the install's first organization, not the recipient's first -membership. A run with no organization in scope still emits and still writes its -rows, and the `notify` node warns audibly naming the topic and the consequence. -A wrong `organization_id` is worse than a null — a null is visibly missing, -while a wrong value is silently authoritative to every report, export and -cleanup script that filters by organization. - -Forward-stamping only. Existing rows are not backfilled and no migration ships. diff --git a/.changeset/standalone-project-resolution-declared.md b/.changeset/standalone-project-resolution-declared.md deleted file mode 100644 index 05c15074e8..0000000000 --- a/.changeset/standalone-project-resolution-declared.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/runtime": patch -"@objectstack/cli": patch ---- - -Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) - -`@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` -returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and -`os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: -`RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares -`z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this -key's vocabulary, and the disagreement survived because nothing ever executed -the schema — `RestServer` cast its config instead of parsing it. - -`StandaloneStackResult['api']` now declares, and the factory now emits, -`projectResolution: 'auto'`. - -**Behaviour on the routing path is unchanged, and that is measured, not assumed.** -Every reader that acts on this key is gated on `enableProjectScoping` first: -`RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` -mounts `[versionedBase]`, and the Dispatcher plugin's two -`enableProjectScoping && … === 'required'` guards short-circuit. With scoping off -the strategy really is moot for routing — which is why this migrates the value -rather than teaching the enum a fourth member. - -**One reader is not gated, and that is the user-visible fix.** `RestServer`'s -discovery handler copies `api.projectResolution` into -`discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares -that field as the same three-member enum. So `GET /api/v1` on every `os serve` -boot advertised a payload the platform's own schema rejects. Clients that -validate discovery — or switch on `scoping.resolution` — now receive a declared -value. - -Both halves are pinned rather than described: `merge-boot-config.test.ts` parses -the CLI's real boot block against `RestApiConfigSchema` and against the discovery -field's enum, and `standalone-stack.test.ts` parses the block the factory -actually returns. Each pin asserts the refusal of `'none'` alongside the -acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now -typed as `StandaloneStackResult['api']`, so it can no longer drift from the -producer without failing `tsc`. diff --git a/.changeset/start-port-forwarding-channel.md b/.changeset/start-port-forwarding-channel.md deleted file mode 100644 index 91a1566018..0000000000 --- a/.changeset/start-port-forwarding-channel.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os start --port` now wins over `$OS_PORT`, and `start` stops printing an address it is not serving (#12992) - -`os start --port N` printed `N` and then bound something else, whenever -`$OS_PORT` was set. Measured on a real boot before the repair: - -``` -OS_PORT=41077 os start --port 41078 - banner: Console: http://localhost:41078/_console/ - curl answers on: 41077 -``` - -Two independent halves, both repaired. - -**The forwarding channel.** `start` wrote the flag into the child's `PORT` and -never cleared the inherited `OS_PORT` beside it. The `serve` child resolves -`readEnvWithDeprecation('OS_PORT', 'PORT')` — `OS_PORT` first — so an explicit -`--port` travelled on the channel its own child ranks **last** and lost to an -environment variable the flag's help text says it overrides. The child's -precedence was correct and is unchanged; the parent now writes the canonical -`OS_PORT` together with its `PORT` alias, so the flag arrives first in the order -the child already reads and every other reader of the child's environment -(app code and libraries that read `process.env.PORT` directly) sees the same -port. No deprecation notice is reachable from either spelling: `OS_PORT` is the -*preferred* name of that pair, and every read site passes `{ silent: true }`. - -Same edit fixes `os start --port 0`, which a falsy guard used to drop entirely — -`0` is a legal port that asks the kernel for a free one (`MIN_PORT = 0`). -Measured before: `os start --port 0` printed `http://localhost:0/_console/` and -bound the inherited `41077`. - -**The lying banner.** `start`'s `Console:` row was a *second* resolution of a -question the child answers for itself, computed with the opposite precedence and -reconciled with nothing. It also asserted a mount it could not know: on the same -boot, `/_console/` answered **404**, because whether a Console is served depends -on the `ConsoleUI` plugin loading in the child. Both facts belong to `serve`, -which already states them together after its `listen()` — the `API:` row always, -the `Console:` row when the plugin actually loaded, both addressed through the -external-base resolver. So `start` no longer prints an address at all, and the -one address it used to print is gone rather than recomputed. - -**User-visible:** `os start` prints one fewer row before the server boots. The -Console URL now comes from the `serve` ready banner, after the bind, and appears -only when a Console is really mounted. Because it is derived from the actual -bind rather than predicted, it is also correct under causes this change does not -touch, including the development auto-shift off a busy port. diff --git a/.changeset/storage-tombstone-download-live-holder.md b/.changeset/storage-tombstone-download-live-holder.md deleted file mode 100644 index 1e735009af..0000000000 --- a/.changeset/storage-tombstone-download-live-holder.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -**Fix:** a tombstoned `sys_file` that something still holds is downloadable again — no 30-day 404 in between (#10246). - -Re-pointing a `sys_attachment` join row onto a file inside its 30-day grace-window tombstone has always been byte-safe: the reap guard re-verifies references at sweep time, finds the new holder, un-tombstones the row and vetoes the reap. But the sweep is the only thing that ever asked, and `sys_file`'s declared lifecycle (`ttl { field: 'deleted_at', expireAfter: '30d' }`) nominates a tombstone only **after** the window expires — measured candidates inside the window: `[]`. So the file simply sat at `status='deleted'` while `GET /api/v1/storage/files/:fileId` and `/files/:fileId/url` refused anything not `committed`. A live attachment could point at a file that 404s for up to 30 days and then silently starts working. - -**What changed:** the two download endpoints stop treating the tombstone as the last word. They now ask the reap guard's own `findFileHolder` — the single definition of "is anything still holding this file?", a union over `sys_attachment` join rows and the `ref_*` ownership columns — and serve the file for exactly as long as that answers yes. - -**What did not change**, deliberately: - -- **No lifecycle verb was added.** There is no un-tombstone, revive or resurrect on the read path; the download writes nothing to the row. Revival remains solely the sweep guard's, which is why the fix is a read-side predicate and not a second revival mechanism (the duplicate-mechanism hazard #10241 avoided). The tombstone stays, and the sweep still reaps when the last holder goes. -- **`pending` is still refused.** Only the `deleted` limb widened; an upload that was never completed has no bytes to promise. -- **Authorization is untouched.** A served tombstone goes through the same `authorizeFileRead` gate as any other file — `AUTH_REQUIRED` (401) and `ATTACHMENT_DOWNLOAD_DENIED` / `FILE_DOWNLOAD_DENIED` (403) are unaffected. Servability is not authorization. -- **Bare kernels are unaffected.** With no data engine there is no holder question to ask, so tombstones stay refused exactly as before. - -The read side and the sweep now answer the same question from the same code, so a file the download path serves is by construction a file the next sweep would veto rather than reap — and the instant the last holder goes, both flip together. That pair is what the new tests pin; the 404 text on the refusal changed from "File not found or not committed" to "File not found or not downloadable" to match (the `FILE_NOT_FOUND` code is unchanged). diff --git a/.changeset/strategy-context-aggregation-method-narrowed.md b/.changeset/strategy-context-aggregation-method-narrowed.md deleted file mode 100644 index 4838f7b965..0000000000 --- a/.changeset/strategy-context-aggregation-method-narrowed.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-analytics": patch ---- - -fix(spec): `StrategyContext.executeAggregate` `aggregations[].method` narrows from `string` to `AggregationFunction` (#12776) - - - -**BREAKING** accept-set narrowing on a published contract, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). - -Two spec-declared surfaces described the same slot and disagreed about its -type: `IDataEngine.aggregate`'s `aggregations[].function` is the closed -six-value `AggregationFunction` enum, while the analytics strategy contract's -`StrategyContext.executeAggregate` declared the same value as -`aggregations[].method: string`. The analytics bridge renames one to the -other, so nothing on the analytics side of that seam was compile-checked -against the engine's vocabulary — a strategy author (very often an AI) got -no compile-time help and hit the bridge's runtime refusal instead. - -FROM → TO: - -- `aggregations[].method: string` → - `aggregations[].method: AggregationFunction` - (`'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`, the spec's - own enum from `@objectstack/spec/data`). One slot, one declaration. - -Who breaks at compile time on upgrade: - -- external CALLERS of `StrategyContext.executeAggregate` that fill `method` - with a value typed `string` (or a literal outside the six) — the values the - bridge already refused at runtime (#11833) now fail `tsc`. -- external IMPLEMENTORS of `StrategyContext` stay source-compatible: a - handler that accepts `method: string` accepts a superset and remains - assignable to the narrowed member. - -The bridge's runtime parse-and-refuse (#11833) stays as defence in depth. -In-repo, `ObjectQLStrategy`'s aggregation locals now carry the enum -end-to-end (`@objectstack/service-analytics`, runtime behaviour unchanged — -the census measured every reachable producer already emitting enum-legal -values only). diff --git a/.changeset/string-family-maxlength-varchar.md b/.changeset/string-family-maxlength-varchar.md deleted file mode 100644 index 9ae926f9d0..0000000000 --- a/.changeset/string-family-maxlength-varchar.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -driver-sql: a string field's declared `maxLength` now shapes the column it gets - -`createColumn` mapped the string family — `string` / `email` / `url` / `phone` / -`password` — with a bare `table.string(name)`, so every column took knex's -default width of 255 and the field's own `maxLength` was never read. A field -declaring a wider bound got a narrower column, and on a dialect that enforces -`varchar` length the write was refused: measured through the driver's own -`initObjects` on MySQL 8.0.46 and Postgres 16, a 300-character value written to -a `maxLength: 1024` column came back `ER_DATA_TOO_LONG` and `22001 value too -long for type character varying(255)` respectively. `schema-drift.ts` has always -treated `varchar(field.maxLength)` as the expected physical shape, so every such -column also reported permanent drift against a table the driver had just -created. - -**This changes emitted DDL for existing declarations.** A field declaring -`maxLength` now gets `varchar(maxLength)` in both directions — wider *and* -narrower than 255. Only newly created columns are affected: `createColumn` runs -on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, never on a column that already -holds rows, so nothing is truncated and no existing column is rewritten. -Narrowing a populated column remains what it was — the `narrow_varchar` drift -op, category `destructive`, behind `os migrate apply --allow-destructive`. - -A declared bound above 16383 characters (MySQL's utf8mb4 `varchar` ceiling) -makes the column `TEXT` rather than clamping it, since a clamp would reinstate -the same defect. Fields declaring no `maxLength`, or a malformed one, keep -`varchar(255)` exactly as before. `lookup` / `user`, `autonumber`, and the -catch-all branch are deliberately unchanged — none of them stores the value the -declared bound describes. - -Two matching corrections in `schema-drift.ts`, so the differ and the emitter -agree on which declarations count: a `maxLength` that is not a positive integer -is no longer read as a bound (`maxLength: 0` planned a destructive `varchar(0)` -ALTER), and a MySQL `TEXT` column is no longer diffed as a `varchar` 65535 wide -— MySQL reports `character_maximum_length` 65535 for `TEXT` where Postgres -reports NULL, so on MySQL alone every bounded unkeyed text column had been -reporting a permanent destructive `narrow_varchar` against itself. diff --git a/.changeset/success-threshold-binds-from-every-failed-status.md b/.changeset/success-threshold-binds-from-every-failed-status.md deleted file mode 100644 index 6b97768923..0000000000 --- a/.changeset/success-threshold-binds-from-every-failed-status.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): `successThreshold` now binds from every status that records a failure, so a declared count above 2 stops being unreachable (#11955) - -`PluginHealthMonitor` consulted `successThreshold` only while a plugin's status -was `unhealthy` or `degraded`. The first success in a recovery wrote -`recovering` — a status that gate did not name — so the **second** success took -the outer `else` and went straight to `healthy` without the counter being read -at all. `failed` was in neither set either, so a plugin whose check threw -recovered on its **first** success. - -The declared value was therefore capped in practice: - -| Status when the successes start | Consecutive successes actually required | -| :--- | :--- | -| `unhealthy` / `degraded` | 2, whatever `successThreshold` said | -| `failed` / `recovering` | 1, whatever `successThreshold` said | - -A declared `successThreshold: 5` was indistinguishable from `2`. The default is -`1`, which is exactly the value at which the defect is invisible — every -declared value above it was the one that misbehaved. - -The counter is now consulted on the way out of every status that records an -observed failure — `degraded`, `unhealthy`, `failed` and `recovering` — so -`successThreshold: N` requires N consecutive successes from each of them, as -its declaration says ("Consecutive successes needed to mark healthy"). The gate -is a map that is exhaustive over `PluginHealthStatus`, so a status added to the -spec fails to compile until it is placed on one side or the other; that is what -`recovering` slipped through before. - -`healthy` and `unknown` still promote on the first success, deliberately: the -count is declared as a **recovery** criterion ("Number of consecutive successes -to recover from unhealthy state") and neither of those records a failure to -recover from — `unknown` is the status `registerPlugin` writes before any check -has run. - -**Behaviour change, only for configs that declare `successThreshold` above 1.** -At the default `1` every route is byte-for-byte what it was: one success has -always been enough and still is. A plugin declaring a higher count now takes -the number of consecutive successes it asked for before it is reported -`healthy`, including after a `failed` round and after an `autoRestart`. - -This also makes #11852's `successCounters` reset load-bearing. That fix cleared -the counter on the thrown failure route, and could not be pinned: the counter's -only read site was unreachable with a stale non-zero value, so any test would -have passed for the wrong reason. With `failed` gated on the counter, a throw -that interrupts a recovery now demonstrably starts the count over. diff --git a/.changeset/surface-context-contract-1610.md b/.changeset/surface-context-contract-1610.md deleted file mode 100644 index 46ffc0a54a..0000000000 --- a/.changeset/surface-context-contract-1610.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`ToolExecutionContext.surfaceContext` (cloud#1610): an optional, advisory description of what the user is currently discussing — Studio pillar, the selected artifact WITH its type discriminator (page/object/dashboard/report), an optional finer selection, and the canvas mode. Strictly additive beside `currentObjectName`/`currentViewName`; consumers must treat every field as optional and never use it for access decisions. diff --git a/.changeset/sys-activity-declares-scheduled.md b/.changeset/sys-activity-declares-scheduled.md deleted file mode 100644 index 2dad6c3445..0000000000 --- a/.changeset/sys-activity-declares-scheduled.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -Declare `sys_activity.type: 'scheduled'` and record its writer in the type census - -A shipped app action writes the value today. `objectstack-ai/hotcrm` -`src/actions/global.actions.ts` builds a `schedule_meeting` action for -`crm_lead`, `crm_contact`, `crm_account`, `crm_opportunity` and `crm_case` -whose body runs `ctx.api.object('sys_activity').insert({ type: EVENT_STATUS -=== 'held' ? 'completed' : 'scheduled', … })`, and `SCHEDULE_MEETING_SPEC` -declares `eventStatus: 'planned'`, so that action always takes the `scheduled` -branch. Read at hotcrm `5eee1bd` on 2026-08-24. - -**Nothing accepts or rejects differently.** Every `sys_activity` field is -`readonly: true`, and `validateRecord` skips readonly fields on both the insert -and the update branch (`objectql/src/validation/record-validator.ts`), so the -`invalid_option` check that enforces a select field's declared options never -runs for one — measured here, not assumed: with the value added to the enum and -nothing else changed, exactly one of the package's 313 assertions moved, the -writer census below, which exists to force that row to be written. Every -behavioural case stayed green, including the one that inserts an undeclared -value into this very column and measures that it lands. Before and after, the -row is stored verbatim. What changes is that the declaration names the value -the platform stores, so the timeline filter offers it and the four generated -locale bundles carry a label for it (`已安排` / `予定` / `Programado`). - -The census (`sys-activity-type-vocabulary.test.ts`) gains the writer row, which -is what forces this to be measured rather than asserted: adding a value to the -enum without inventorying its writer fails that pin. Two further facts are -recorded there in passing — `completed` has a second writer at the same app -(`src/actions/contact.actions.ts`), and the census's in-repo sweep cannot see -either of them, because an app's server-side action reaches this column directly -through `ctx.api`. diff --git a/.changeset/sys-activity-type-open-vocabulary.md b/.changeset/sys-activity-type-open-vocabulary.md deleted file mode 100644 index 6983ad2434..0000000000 --- a/.changeset/sys-activity-type-open-vocabulary.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -Say out loud that `sys_activity.type` is an open, author-extensible vocabulary -— the declared options are the platform's **built-in** set, not a closed enum - -An author reading the declaration learned "writing another value will be -rejected". That was false in three independent ways, and the declaration was -the only place that did not say so. - -1. Every field on `sys_activity` is `readonly: true`, and `validateRecord` - skips readonly fields on both write branches, so the `invalid_option` check - a `select` normally implies **never runs** on this column. -2. ADR-0052 §5b.2 `activityMilestones[].type` is `z.string().optional()` in - the spec and is forwarded verbatim by the audit writer - (`if (milestone.type) activityType = milestone.type`) — a shipped, - documented, author-facing channel straight into the column. -3. An app's own server-side action writes the column directly - (`ctx.api.object('sys_activity').insert({ type: … })`); no grep of this - repository can see those sites. - -Maintainer ruling, 2026-08-24 (#11507, direction 4 of four): the column **is** -an open vocabulary, ADR-0052 §5b.2 **stays** a sanctioned write path, and -every closed map over this vocabulary is now the bug. The status quo was the -one option more dangerous than either end state — most of all to an AI writing -metadata, which reads the declaration and believes it. - -So the declaration now carries the semantics, in the field's own -`description` — the slot the spec declares for exactly this and, unlike a -source comment, one the contract carries wherever the metadata goes (the -metadata API, the i18n bundles, whatever an author or an AI reads about this -field). No new schema concept was invented: `FieldSchema` has no -open/closed-vocabulary key, and the pin measures that rather than asserting -it, so the day `packages/spec` grows one this declaration is told to move. - -Nothing about enforcement changed — that was direction 3 and it was **not** -ruled. `validateRecord` is untouched, the built-in set is unchanged (twelve -values), and both existing vocabulary tests keep every assertion they had. -What changed in them is what a red MEANS: the two cases that used to be filed -as "a defect, characterized — delete these when enforcement lands" now measure -a ruled contract, and say that rejecting an author-contributed value is a -contract change to re-open #11507 over, not a fix to adapt them to. diff --git a/.changeset/sys-file-organization-stamping.md b/.changeset/sys-file-organization-stamping.md deleted file mode 100644 index ef46134bb0..0000000000 --- a/.changeset/sys-file-organization-stamping.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -"@objectstack/service-storage": minor ---- - -fix(service-storage): stamp `sys_file` with the acting organization, and backfill the rows that were never stamped (#12745) - -`sys_file` is a tenancy-ENABLED object — it declares no `tenancy` key, so -`isTenancyDisabled()` reads `false` and `applySystemFields` provisions -`organization_id` on it. Nothing ever wrote that column: -`StorageMetadataStore.createFile` inserted with **no execution context at all** -(the string `context` appeared 0 times in `metadata-store.ts`), so the SQL -driver's `injectTenantOnInsert` had no `tenantId` to stamp from and every row -landed NULL. Both callers already held the session — `storage-routes.ts` reads -`owner_id: session?.userId` ten lines below each `createFile` — so the -organization was in hand and simply had nowhere in the signature to go. - -Maintainer ruling 2026-08-28 on #12745: **A with backfill** — stamp forward AND -repair the existing rows. Both halves ship here. - -**Forward stamping.** `createFile(rec, context?)` takes a new optional -`StorageWriteContext` (`{ organizationId }`) and passes it to the engine as -`{ context: { tenantId } }`. The column is deliberately NOT written onto the -payload: whether the object has a tenant column, and whether an explicit value -on the row wins, are the driver's answers (`injectTenantOnInsert` → -`resolveTenantField`), and a metadata store re-deciding them one package away -from the schema is how a stamp starts failing on installs that opted the object -out. Both upload doors thread the session's active organization, and the -plugin's session bridge now reports it (`session.session.activeOrganizationId`, -the platform's existing spelling). ⛔ No membership fallback: here the value -becomes a *wall*, and a file stamped from a guessed membership is a file its -uploader can no longer see from the organization they were acting in. A session -with no active organization stamps nothing, exactly as before. - -**Why the backfill is not optional.** The SQL driver's tenant predicate is -NULL-tolerant (`organization_id = :tenant OR organization_id IS NULL`), but -Layer 0 AND-composes a strict `organization_id = ` above it and -"the conjunction is the strict equality alone". Forward-only stamping would -therefore split the table: new files org-walled, every existing NULL-org file -invisible to **every** principal. (`single` posture is inert — -`computeTenantLayer0Filter` returns `null` — so single-tenant installs are -unaffected either way.) - -**The backfill.** A one-off, idempotent, dry-run-first sweep -(`backfill-sys-file-organizations.ts`), following the tree's own precedent in -`plugin-approvals`. It derives each row's organization from the file's HOLDERS — -the exclusive field reference (`ref_object`/`ref_id`) and every `sys_attachment` -join row — and stamps **only where they all answered and all answered the same -organization**. ⛔ Rows that cannot be derived unambiguously stay NULL and are -REPORTED, never guessed, with the residual-NULL count and its per-reason -breakdown on the report and in the rendered text — for the dry run as well as -the applied run. It is a one-off operational module: not exported from the -package index and not shipped in `dist`. - -⛔ Scope: `sys_file` only. The precedent requires a maintainer order per table -and the ruling is that order for this one table — `sys_upload_session` sits in -the same package with the same NULL column and is deliberately not swept. - -**Compatibility.** `createFile`'s new parameter is optional and -`StorageRoutesOptions.resolveSession` only WIDENS its return type -(`{ userId? }` → `{ userId?, organizationId? }`), so existing resolvers and -callers keep compiling and keep their current behaviour. diff --git a/.changeset/sys-file-stranded-orphan-inventory.md b/.changeset/sys-file-stranded-orphan-inventory.md deleted file mode 100644 index 3d85a28201..0000000000 --- a/.changeset/sys-file-stranded-orphan-inventory.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/service-storage": patch -"@objectstack/cli": patch ---- - -feat(storage): report-only inventory for stranded `sys_file` orphans, plus `os storage orphans` (#10950) - -The tombstone repairs in #10171 (update verb) and #10240 (delete verb) are forward-only: -they changed what the next write does and touched no row already written. Every -attachments-scope file orphaned before them still sits at `status='committed'` with -`deleted_at` NULL and no `sys_attachment` join row — and `sys_file`'s declared lifecycle -nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or -`retention { onlyWhen: { status: 'pending' } }`, so such a row matches **neither**. It is -never a candidate, the reap guard is never asked about it, and its bytes are never -reclaimed. The leak is permanent rather than late. - -This ships the measurement half only, per the maintainer's ruling on #10950: - -- `inventoryStrandedFileOrphans()` — a read-only reconciliation pass that walks - attachments-scope committed `sys_file` rows and reports how many are stranded, their - byte magnitude, and why each excluded row was excluded. `formatStrandedOrphanInventory()` - renders it; those two plus the inventory's result types are what - `@objectstack/service-storage` publishes — the shared ownership predicate stays internal, - since nothing outside the package pulls on it. -- `os storage orphans` — the operator-invoked surface, with `--json` for a machine-readable - payload. There is no `--apply` and no write path, deliberately. - -**It writes nothing, tombstones nothing and deletes nothing.** Authorising the destructive -backfill is a separate decision that these numbers exist to inform; a tombstone written -here would start a 30-day clock ending in an irreversible byte delete. - -The ownership question is not reimplemented. `createSysFileReapGuard`'s "is anything still -holding this file?" test — zero `sys_attachment` join rows **and** empty `ref_*` ownership -columns — is extracted as `findFileHolder()` and called by both the guard and the -inventory, so "the same question, never a weaker one" is a property of the code rather -than a claim in a comment. A file with zero join rows that is `ref_*`-owned (ADR-0104 / -#3459) is a live file and is excluded from the count. - -Behaviour of the reap guard is unchanged — the extraction is a pure refactor, and the -guard's existing pins cover it. Both counts are labelled `attachments` scope: files in the -other scopes are governed by the field-reference seam and are reconciled by -`verifyFileReferences`, which skips attachments-scope files, so the two passes partition -the population rather than overlapping. diff --git a/.changeset/sys-metadata-activation-ledger.md b/.changeset/sys-metadata-activation-ledger.md deleted file mode 100644 index 9a1cd4f094..0000000000 --- a/.changeset/sys-metadata-activation-ledger.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/spec": minor ---- - -feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) - -Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone -family gets **one** data-plane platform object, declared beside its siblings so -it needs **zero `packages/spec` schema or contract surface** — it is an ordinary -platform object, not a metadata type. (The one spec file touched is the -mechanical name census described below, not protocol surface.) - -The whole schema, per §4: `metadata_type` · `name` · `package_id` · -`organization_id` (nullable, **reserved** — NULL on this entire line; the -per-org dimension is an additive column later, never a redesign) · `active`. -An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); -amendment ruling 2 removed them — there is **no recorded linkage** between a -clone and its base, matching the landed #11513 posture ("an ordinary org-owned -set with no upgrade linkage"). The pin test asserts the column set by EQUALITY -and names both removed columns separately, so re-growing the linkage is loud. - -Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled -as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That -spelling is load-bearing, and the two obvious alternatives are both wrong here: - -- bare `unique: true` on a declared index is the positional spelling of - `'global'` — installation-wide over exactly the listed columns — and is - already warned by lint `unique/unscoped-declared-index` in 17.x; -- a hand-written `['metadata_type', 'name', 'organization_id']` composite is - NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row - by construction, so that index would enforce **nothing at all** (#5030, - measured) and one artifact could carry two contradictory `active` rows. - -`'organization'` is the arm that closes exactly that hole: the driver prepends -`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which -is what §4's "NULL-collapsed" names. - -The name is also registered in `@objectstack/spec`'s platform-object name census -(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a -curated set of REAL names, not a `sys_`-prefix pattern, precisely so a -cross-reference check can tell `sys_user` (real) from a fictional -platform-prefixed name; its module contract is explicit that "adding an object -to a platform package means adding its name here", and the owning package's -conformance pin fails otherwise. This is a one-name roster registration, **not** -protocol or schema surface — the ledger remains an ordinary platform object with -no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its -user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` -now answers `true`, so lint stops reading a reference to the ledger as a typo. - -**No behavior change.** This leg ships the declaration only — the enable/disable -actions that write the ledger and the per-runtime consult points that read it -are separate legs, and nothing in the tree reads the object yet. Absence of a -row means the packaged default (**active**), so an empty ledger changes nothing -anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. -The object deliberately declares **no `lifecycle` block** — unlike its telemetry -siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable -configuration, and reaping one would silently re-arm an artifact an -administrator disabled. diff --git a/.changeset/sys-secret-cross-producer-reference-union.md b/.changeset/sys-secret-cross-producer-reference-union.md deleted file mode 100644 index 1ea2b8b221..0000000000 --- a/.changeset/sys-secret-cross-producer-reference-union.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -feat(cli): build the cross-producer `sys_secret` reference union — the primitive a safe orphan sweep needs (#12663) - -`sys_secret` has three privileged producers, each holding its handle in a -column of its own: `SettingsService` (a bare `sec_…` in -`sys_setting.value_enc`), the engine's `secret`-typed field channel -(`secret:` on an arbitrary business row) and the datasource credential -binder (`sys_secret:` at a datasource artefact's -`external.credentialsRef`). Nothing enumerated all three, so the only sound -deletion predicate — "attributable AND unreferenced by the COMPLETE union" — -had no union to stand on. `packages/cli/src/utils/secret-reference-union.ts` -is that union, read-only across all three surfaces. - -Why the shipped report-only classifier is not enough, reproduced against real -code in the new test file: `classifySysSecretRows` attributes a row by -`(namespace, key)` membership in the settings manifests' encrypted specifiers, -and that is a **name match, not ownership** — `sys_secret` carries no producer -column and the three producers write those two columns with three different -meanings. A live, engine-owned credential on an object named like a settings -namespace, with a field named like a specifier key, classifies `orphaned` -today. There is no recovery from acting on that: the audit trail records -digests, not handles, so nothing can name the destroyed handle afterwards. - -Completeness is therefore the whole contract, and it is structural rather than -asserted: - -- the family set is closed and the assembler takes a `Record` over it, so - omitting a producer is a type error, not a smaller union; -- a read that did not happen is a declared **gap**, never an empty answer — a - missing driver, a throwing read, an unparseable artefact, or a host that did - not declare its code-defined datasources all make the union - `complete: false`, and `assertSecretReferenceUnionComplete` refuses it with - the ADR-0112 pair `PRECONDITION_REQUIRED` / 428; -- family 2 is enumerated from the metadata registry on every call, because its - holders are every `secret`-typed field on every registered object, - tenant-authored ones included — a newly registered secret field is in the - union with no code change. - -Reads go to the driver through the engine's public `getDriverForObject()`: the -`secret:` ref only exists at that level (the read path masks it -unconditionally), and any scoped read would silently under-report — the -direction that deletes live credentials. - -`patch` rather than `minor`: this adds no surface to the package's entry -barrel and no command or flag. It is an internal primitive whose named reader -is the deletion half of #8103, in this same package; publishing it as an -external API is a separate decision with its own changeset. Read-only by -construction — nothing here writes, deletes or decrypts, and it contains no -deletion command, dry-run or sweep. diff --git a/.changeset/sys-secret-tri-producer-field-help.md b/.changeset/sys-secret-tri-producer-field-help.md deleted file mode 100644 index a90e3f4b6b..0000000000 --- a/.changeset/sys-secret-tri-producer-field-help.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): `sys_secret` field help stops asserting the settings-only reading (#12550) - -`sys_secret` has **three** privileged producers (#4270) — the object's own -`managedBy` note has said so for a while — but its most load-bearing field -descriptions still described a settings-only table. Those strings are not -internal comments: they are the field help an operator reads in the -`sys_secret` grid, and they compile into the shipped translation bundles. - -Measured on `origin/main@f93df4db`, by producer symbol rather than by line: - -| producer | `namespace` | `key` | the reference lives at | -|---|---|---|---| -| `SettingsService` (`settings-service.ts`, `secretStore.insert`) | settings namespace | specifier key | `sys_setting.value_enc` | -| engine `encryptSecretFields` (`objectql/src/engine.ts`) | **object name** | **field name** | a `secret:` ref on the business row itself | -| datasource credential binder (`datasource-secret-binder.ts`) | **caller-supplied**, default `datasource` | datasource name | the artefact's `sys_secret:` credentialsRef | - -So `'Settings namespace this secret belongs to.'` / `'Specifier key within the -namespace.'` / `'Opaque handle referenced by sys_setting.value_enc.'` were each -true of one producer out of three, and the pair they describe was presented as -if it identified an owner. That is exactly the `(namespace, key)` attribution -reading #8103's re-measurement rejected — the reason -`sys-secret-orphan-report.ts` reports a row it cannot attribute as -`'unattributable'` rather than `'orphaned'`. Field help asserting the rejected -reading is the safety-relevant direction of this drift. - -Corrected here: the object description, and the `namespace` / `key` / `id` -field descriptions, now name the producer-scoped reality and point at -`managedBy`. The `en` bundle was regenerated with the repo's own -`pnpm i18n:extract`; the three translated locales carried translations of the -superseded English, so their four affected leaves were re-translated by hand — -the action `.objects.generated.ts`'s own header prescribes when a -source string changes — and the bundles plus their `--source-hashes` -companions then come from one extract run. - -Text only. No field is added, removed, renamed or re-typed; no validation, -persistence or access rule moves; every `sys_secret` payload that parsed before -parses identically. ⛔ A producer/owner column stays out of scope — that is a -persist-path change and belongs to whoever takes that decision. diff --git a/.changeset/testing-subpath-exports-esm-only.md b/.changeset/testing-subpath-exports-esm-only.md deleted file mode 100644 index 0f0bc3eda9..0000000000 --- a/.changeset/testing-subpath-exports-esm-only.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/service-cluster": minor ---- - -fix: the `./testing` subpaths are ESM-only — they no longer advertise a `require` condition vitest refuses to serve (#12985) - -Both packages published their test-harness subpath as a dual entry point: - -```jsonc -// FROM — @objectstack/metadata-core and @objectstack/service-cluster -"./testing": { - "types": "./dist/testing.d.ts", - "import": "./dist/testing.js", - "require": "./dist/testing.cjs" -} - -// TO -"./testing": { - "types": "./dist/testing.d.ts", - "import": "./dist/testing.js" -} -``` - -The `require` half was a promise neither package could keep. Both subpaths -re-export `vitest`, and vitest **refuses** to be loaded from CommonJS by -design — its CJS entry is a single `throw`: - -``` -node -e "require('@objectstack/metadata-core/testing')" -Error: Vitest cannot be imported in a CommonJS module using require(). Please use "import" instead. -``` - -The emitted bytes parse; the load fails inside vitest's own entry, for every -consumer and every code path. So the condition could never resolve to working -code, on any release, since it was first declared. It is removed rather than -repaired because the failure is not ours to fix: a test harness has no business -advertising a `require` condition when the test runner it re-exports does not -serve one. - -**Nothing that worked stops working**, and that is why this is not filed as a -breaking removal. A CJS consumer that resolved through the old condition got a -hard `Error` at load; it now gets a resolution error from node instead — a -different message for the same non-working call, and an earlier and clearer -one. The `import` condition, the types and the runtime API are untouched, and -every in-repo consumer already reaches these subpaths through `import` -(`@objectstack/metadata-fs`, `@objectstack/metadata-protocol`, -`@objectstack/rest`, `@objectstack/runtime`, `@objectstack/service-cluster-redis`). - -**If you did spell it as `require`** — `require('@objectstack/metadata-core/testing')` -or `require('@objectstack/service-cluster/testing')` — switch the call to -`await import('@objectstack/metadata-core/testing')`, or move the calling -module to ESM. That is the same change the old condition already forced on -you, one error message earlier. - -`dist/testing.cjs` is still emitted (both packages build every entry in both -formats) and still parsed by `pnpm check:dual-build-cjs-loads`; it is simply no -longer reachable through the manifest. Removing it from the build is a -tsup-config change with its own risks and is not folded in here. diff --git a/.changeset/thin-yen-keeps-no-cents.md b/.changeset/thin-yen-keeps-no-cents.md deleted file mode 100644 index 8b01f0042f..0000000000 --- a/.changeset/thin-yen-keeps-no-cents.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`CurrencyConfigSchema` no longer materializes the `precision` default onto a configuration the schema itself would refuse as authored (#11423). A bare `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }` used to parse to `precision: 2` — and the #7918 rule rejects an authored `precision: 2` against JPY's 0 fraction digits, with the materialized and authored spellings indistinguishable by design — so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse: `parse(parse(x))` threw for an input `parse(x)` accepts. - -Mechanism (the #9689 idempotent-materialization ruling, applied to its recorded currency twin): one conditional in the `.overwrite()` — when `currencyMode` is `fixed`, no `precision` was authored, and the currency's ISO 4217 / CLDR fraction digits contradict the default `2` (the JPY/KRW/KWD class), the parsed output OMITS `precision` instead of baking a value the schema refuses. Renderers already derive display width from the currency when the key is absent (objectui#4361), so absent is the honest spelling. Every other combination keeps byte-identical output: an authored `precision` is untouched, a bare fixed 2-fraction-digit config (USD/EUR/CNY…) still materializes `precision: 2` at its shape position, and `dynamic` mode and non-CLDR codes (crypto/custom, fail-open) keep materializing — none of those can be refused. The #7918 rejection of an authored contradictory `precision` is unchanged, message and path included. diff --git a/.changeset/tombstone-citation-unwritten-decision.md b/.changeset/tombstone-citation-unwritten-decision.md deleted file mode 100644 index 3315d59611..0000000000 --- a/.changeset/tombstone-citation-unwritten-decision.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `namespace` tombstone's rejection message no longer cites "ADR-0006 D4" — a decision letter no version of ADR-0006 declares. The retirement itself is unchanged and still enforced; the message keeps the fix (`name: "sys_user"`) and the #4001 parse-path history. The letter citation comes back when the retirement decision is actually written into an ADR. diff --git a/.changeset/translation-refs-flows-leg.md b/.changeset/translation-refs-flows-leg.md deleted file mode 100644 index 358e5c8ee1..0000000000 --- a/.changeset/translation-refs-flows-leg.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`validate-translation-references` now checks the `flows` group — an authored key naming a -flow, screen node or screen field that does not exist warns instead of resolving to nothing - -The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised -top-level namespace is skipped and never reported, and `flows` was one of them. So a -bundle keyed to `flows..screens..fields.` parsed, shipped, and -silently resolved to nothing — the wizard rendering its source-locale string while every -other label on the screen was translated, which is the exact failure this rule exists for, -one namespace over. - -All three levels are exact-match identifiers with an enumerable universe, so the leg -mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen → -`FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are -`warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert, -not broken), and each names the declared universe it resolved against. - -Two shape facts the collector respects, both measured against the schemas rather than -assumed — either one read the obvious way would have made the leg a false-positive -generator: - -- **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`, - `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the - runner pauses on, so the universe is collected through `walkFlowNodes` rather than the - flat `flow.nodes`. -- **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen - (`config.objectName`) renders that object's own create/edit form and declares no - `config.fields`; its input labels resolve through `objects..fields.*`, so a - field key there is reported with that redirect rather than a bare "not declared". - -A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type, -not as a missing node. diff --git a/.changeset/translation-submit-label-retired.md b/.changeset/translation-submit-label-retired.md deleted file mode 100644 index 5dfa483a2a..0000000000 --- a/.changeset/translation-submit-label-retired.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the component-translation `submitLabel` copy key (#10926, 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). - -The `pages..components.` copy face is measured, not mirrored: each -key exists because some component in `ComponentPropsMap` declares it. -`submitLabel`'s only declarer was `element:form`, and #9249 retired that -element whole — so the key had no declared component left to translate, and -the resolver overlay was its only reader. The maintainer ruled retire over -re-anchor (#10926): the live form surface (`object-form`) speaks `submitText` -(`I18nLabelSchema`), localizable at its own authoring site, so re-anchoring -would have widened the face for one word. The acknowledged cost is that the -bespoke-component route loses that one word. - -**What is refused:** `submitLabel` in any `pages..components.` -translation entry, and its `submit` alias spelling — both now land on a -`guidance` prescription in the strict unknown-key rejection (the face is -`.strict()`, so the strict-delete route applies: no `retiredKey()` tombstone, -the shape simply no longer declares the key). - -**What stays:** the other five copy keys (`title`, `description`, `label`, -`placeholder`, `emptyText`), the bespoke-component route for them, and the -shared `PAGE_COMPONENT_COPY_KEYS` list (now five entries) that drives both -`translatePage`'s overlay and the CLI `i18n-extract` skeleton — one list, both -sides import it, so extractor and resolver narrow together. - -The retirement kit: - -- strict-delete at the schema (`packages/spec/src/system/translation.zod.ts`): - key and `submit` alias dropped; `guidance` tombstones carry the prescription -- `PAGE_COMPONENT_COPY_KEYS` drops the slot - (`packages/spec/src/system/i18n-resolver.ts`) — the resolver no longer - overlays the key and the extractor no longer offers it -- ADR-0087 registration: D2 conversion - `translation-component-submit-label-removed` (protocol 18), wired into the - step-18 chain — `os migrate meta --from 17` strips the key from stored - translation bundles and items (pure lossless delete; nothing read it since - #9249) -- pin tests flipped, not deleted (`translation.test.ts` refusal pins assert - the prescription; `i18n-resolver.test.ts` pins that an off-spec bundle entry - carrying the retired key is ignored, not overlaid) -- generated baselines/docs follow the schema (json-schema manifest, - spec-changes, upgrade guide, api-surface signatures, reference docs) - -## FROM → TO - -```ts -// before — a component-translation entry could carry a submit label -translations: [{ - 'zh-CN': { - pages: { - sales_home_page: { - components: { new_lead_form: { submitLabel: '创建' } }, - }, - }, - }, -}] - -// after — delete the key (nothing has read it since #9249); submit copy for -// the live form surface is authored on the component itself, where it is -// localizable inline -{ - type: 'object-form', - properties: { - objectName: 'lead', - submitText: { en: 'Create', 'zh-CN': '创建' }, - }, -} -``` diff --git a/.changeset/turso-json-column-type-asymmetry-declared.md b/.changeset/turso-json-column-type-asymmetry-declared.md deleted file mode 100644 index 185b4f1da9..0000000000 --- a/.changeset/turso-json-column-type-asymmetry-declared.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/driver-turso': patch ---- - -chore(driver-turso): declare and pin the `Field.json` column-type asymmetry between the local and remote transports (#12586) - -`TursoDriver` is dual-transport, and one declared `Field.json` becomes a -different physical column on each. Local/replica mode extends `SqlDriver` and -lets knex spell it (`table.json(name)` — a `json` column); remote mode never -touches knex and spells its own SQLite types in -`RemoteTransport.mapFieldTypeToSQL` (`TEXT`). Nothing in the tree said whether -that was a design or an oversight — a grep found the two `mapFieldTypeToSQL` -lines and one passing comment — and no test would have gone red if either side -moved. - -⛔ Nothing is broken and no behaviour changes here. Both transports round-trip -every `VALUE_ROUNDTRIP_CASES` value faithfully today and did before this PR. - -**Why it is still worth recording.** Every column type in this driver is -spelled differently by the two halves — `varchar(255)`/`TEXT`, -`float`/`REAL`, `boolean`/`INTEGER` — and for all of those the difference is -cosmetic, because SQLite derives affinity from substrings of the declared type -name and both spellings land in the same class. `json` is the one that does -not: it matches none of SQLite's affinity markers, so it carries **NUMERIC** -affinity and converts number-like input on the way in, while `TEXT` converts -nothing. Measured on the shared fixture, that is not theoretical — a declared -`Field.json` holding the native `123` is an **INTEGER cell locally and a TEXT -cell remotely**, with `find()` answering `123` on both. Equal answers, unequal -bytes: the #11535 class in its quiet phase, where the next codec change has no -reason to be kind to both. PR #12585's ablation is the same fact in its loud -phase — the pre-#12380 `json` branch broke the two transports by *different* -counts, diverging on `s_0123`, because only the local column had NUMERIC -affinity to destroy a bare `'0123'` with. - -**What lands:** - -- The declaration, at the site a reader lands on when they ask why this returns - `TEXT` — `RemoteTransport.mapFieldTypeToSQL`'s doc comment: the full - local/remote type table, which rows are cosmetic and which one is not, the - affinity mechanism as the "why it is safe today", and the instruction to - delete or invert the pin rather than patch it green. -- The pin, `turso-json-column-type-asymmetry.test.ts`, driven by the same - `VALUE_ROUNDTRIP_FIELDS` / `VALUE_ROUNDTRIP_CASES` table the round-trip - conformance suite uses. It asserts each transport's declared types from the - **catalog**, demonstrates the affinity mechanism with raw SQL that bypasses - the driver codec, and asserts that the set of cases whose on-disk storage - class differs is exactly `{n_int, n_real}` — so convergence (an empty set) is - as red as one side drifting (a longer one). -- A note in `turso-value-roundtrip-conformance.test.ts` saying it is - deliberately blind to this, since it is green either way. - -⛔ Convergence (making both transports emit one type) is **not** done here. -It changes what new columns are physically declared as and needs the -un-measured "why did remote choose `TEXT`?" answered first; #12586 ruled it a -separate decision. - -Grade: `patch`, argued rather than defaulted. Not `minor` — no new public API, -no widened accept set, no behaviour change, and the emitted DDL is byte-for-byte -what it was. Not `skip-changeset` either, though the only executable code this -PR ships is a test: what becomes a checked invariant here is a property of the -published package (which physical column a declared field gets on each -transport), and the CHANGELOG line is the record a future reader needs when -this guard goes red on them. diff --git a/.changeset/twelve-dataengine-contract-adoption.md b/.changeset/twelve-dataengine-contract-adoption.md deleted file mode 100644 index f93759ea08..0000000000 --- a/.changeset/twelve-dataengine-contract-adoption.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The five engine members the #11833 sweep measured as "real on ObjectQL, consumed cross-package, recoverable only through consumer-local structural re-declarations" are now on the contract, per the 2026-08-25 maintainer ruling (#12248). `IDataEngine` gains five optional members: `resolveEffectiveDatasource?(objectName)` (the #5288 effective-datasource name, `undefined` = rides the deployment default), `getDriverForObject?(objectName)` (the public driver-routing read, `IDataDriver | undefined`), and the datasource-lifecycle trio `registerDatasourceDef?` / `markDatasourceUnavailable?` (`kind: 'blocked' | 'failed'`, framework#3828) / `clearDatasourceUnavailable?` (#12010's inventory, adjudicated per the ruling's item 4). Engines without datasource routing stay conformant — every member is optional, preserving each graceful-degradation seam. And `IObjectQLEngine.getObject` / `EngineSchemaRegistryView.getObject` now return `ServiceObject | undefined` — the spec's own registered-object type (authored state, ADR-0122) — instead of `unknown`, so consumers reading `fields` / `external` off a registered object no longer need a private structural re-declaration to do it; an engine or registry fake answering a non-conforming shape now fails compile at the member instead of drifting silently (the #4251 gap, closed at this seam). diff --git a/.changeset/type-compat-text-signature-qrcode.md b/.changeset/type-compat-text-signature-qrcode.md deleted file mode 100644 index ab1c041003..0000000000 --- a/.changeset/type-compat-text-signature-qrcode.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`isCompatible` now reports a TEXT column as exactly compatible with `signature` and `qrcode` fields (#12117). The SQL ⇆ field-type matrix in `type-compat.ts` had drifted from the platform's own emitted physical shape: after #11875 (maintainer ruling 2026-08-25) `sql-driver.ts` emits both types in its TEXT family alongside `richtext` / `code`, and their stored value is a string — routinely a data-URI — per `STRING_VALUE_TYPES` and `valueSchemaFor`. The `text` row's exact set never listed them, so `IExternalDatasourceService.validateObject` diffing a table the driver itself had created reported a `type_mismatch` against the very field type that wrote the column, and `generateObjectDraft` would not offer either type for a TEXT column. - -`binary` is unchanged, and `signature` stays in its exact set. The matrix is many-to-many by design — each row answers "which field types can THIS column serve?" and `isCompatible` is keyed on the column, so `text` is already exact under `text` / `uuid` / `enum` and `tags` / `multiselect` under `json` / `array`; a type joining one row does not leave another. Measured across the monorepo when the `text` row moved: no path stores a `signature` field value as a binary payload (the driver's only `binary()` column emission is the remote-column introspection mapping, never a field-type mapping), so the `binary` entry is import-side reach into foreign schemas — the only kind of schema this matrix is asked about — on the same content-class reading that puts `file` and `image` on that row, neither of which round-trips as bytes either (both are `FILE_REFERENCE_TYPES`, stored as reference-id strings). diff --git a/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md b/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md deleted file mode 100644 index 1a2516187a..0000000000 --- a/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575) - -**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). - -These were two more instances of the #8691/#8744 silent no-op class: -console-registered widgets on `@objectstack/cloud-connection`'s published -Setup pages, reachable through the component type union's open string arm, -with registered renderers but no `ComponentPropsMap` row — so the #5068 -component-props gate's dispatch skipped them as unregistered and any authored -key rode through every validator in silence. - -The new rows are strict and **empty**, measured from the renderers' actual -read points at the objectui pin, not from the registrations' declared-input -lists (#8691/#8744 record where those diverge — here the two happen to -agree): both registrations discard the schema node entirely -(`() => `, `() => `) and neither -component function takes a prop, so the widgets accept **no configuration at -all**, and an authored key is now a publish-time refusal naming the surface -instead of a silent no-op. - -**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the -shape both plugin-shipped pages (`cloud_connection_settings`, -`marketplace_installed`) author today, byte-identically. Node-level keys -(`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component -node, and the refusal's guidance says so. - -## FROM → TO - -```ts -// before — parsed green everywhere; the panel polls on its own schedule anyway -{ - type: 'cloud-connection:panel', - properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing -} - -// after — any key is a publish-time refusal naming the zero-prop surface; -// write the measured shape -{ - type: 'cloud-connection:panel', - properties: {}, -} -``` - -There is deliberately no automatic rewrite: a key authored on either widget -configures nothing and is removed, not renamed — behaviour that seems to need -one is a renderer capability request against objectui, not a metadata key. -`os migrate meta` surfaces the change as a structured TODO (semantic entry -`ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this -refusal is not part of the v17.0.0 cut). - - diff --git a/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md b/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md deleted file mode 100644 index bab79821b7..0000000000 --- a/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `mcp:connect-agent` in `ComponentPropsMap` — undeclared keys on the widget are refused (#12344) - -**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). - -This was a third instance of the #8691/#8744 silent no-op class (#11575 closed -the previous two): a console-registered widget on `@objectstack/mcp`'s -plugin-shipped Setup page (`CONNECT_AGENT_PAGE`), reachable through the -component type union's open string arm, with a registered renderer but no -`ComponentPropsMap` row — so the #5068 component-props gate's dispatch skipped -it as unregistered, any authored key rode through every validator in silence, -and door 3 of the mcp canonical-envelope gate (#12269) had to carry a standing -exemption for the type (deleted here, with its two guard pins). - -The new row is strict and **empty**, measured from the renderer's actual read -points at the objectui pin, not from the registration's declared-input list -(#8691/#8744 record where those diverge — here the two happen to agree): the -registration discards the schema node entirely (`() => `) and -the component function takes no parameters — every value it renders comes from -`/discovery`, i18n and its own state — so the widget accepts **no -configuration at all**, and an authored key is now a publish-time refusal -naming the surface instead of a silent no-op. - -**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the -shape the plugin-shipped page (`connect_agent`) authors today, -byte-identically. Node-level keys (`visibleWhen`, `id`, `style`, …) are -unaffected: they live on the component node, and the refusal's guidance says -so. - -## FROM → TO - -```ts -// before — parsed green everywhere; the widget reads /discovery on its own -{ - type: 'mcp:connect-agent', - properties: { serverUrl: 'https://example.test/mcp' }, // silent no-op: the widget reads nothing -} - -// after — any key is a publish-time refusal naming the zero-prop surface; -// write the measured shape -{ - type: 'mcp:connect-agent', - properties: {}, -} -``` - -There is deliberately no automatic rewrite: a key authored on the widget -configures nothing and is removed, not renamed — behaviour that seems to need -one is a renderer capability request against objectui, not a metadata key. -`os migrate meta` surfaces the change as a structured TODO (semantic entry -`ui-mcp-connect-agent-unknown-keys-refused`, protocol major 18 — this refusal -is not part of the v17.0.0 cut). - - diff --git a/.changeset/updatemany-write-coercion.md b/.changeset/updatemany-write-coercion.md deleted file mode 100644 index 1fa62ce073..0000000000 --- a/.changeset/updatemany-write-coercion.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): route `updateMany()`'s payload through `formatInput` / `applyWriteColumnMap` (#11223) - -`updateMany()` was the only write door in `sql-driver.ts` that passed the caller's `data` -straight to `builder.update(data)`. Every other one — `create`, `update`, `bulkCreate`, -`upsert`, `rotatedUpdateById` — applies `applyWriteColumnMap(object, formatInput(object, data))` -first, and the WHERE side of the very same bulk statement was already being translated by -`applyFilters`. Measured on SQLite, live PostgreSQL 16.13 and live MySQL 8.0.46: - -- **`json` and `Field.multiple` values were refused.** Nothing stringified the structured - value for the bind, so each dialect refused it in its own voice: `22P02 invalid input - syntax for type json` on Postgres, `SQLite3 can only bind numbers, strings, bigints, - buffers, and null` on SQLite, and on MySQL the array expanded into the SET list itself - (``set `tags` = 'y', 'z'``) — a syntax error rather than a bind error. `update()` wrote - the identical values correctly in the same run. -- **A federated `external.columnMap` object's bulk update named a column that does not - exist.** The WHERE was mapped and the SET was not, in one statement: - ``update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'`` → `no such - column: name`. The door was unusable on every remapped external object. -- **Temporal values were stored verbatim**, silently. On SQLite a zone-naive - `'2026-05-06 07:08:09'` landed as-is — the pre-#3912 storage form - `needsLegacyDatetimeRepair` exists to repair on read, written into a column - `canonicalDatetimeFields` had already certified as canonical and therefore stopped - repairing. Measured end to end: a range filter over that calendar day returned only the - `update()`-written row, with the bulk-written row on disk carrying the right day and - invisible to the query. On live Postgres the same literal was resolved in the **server's** - timezone rather than UTC — `2026-05-06 07:08:09` stored as `2026-05-05T23:08:09.000Z`, a - silent 8-hour instant shift on an `Asia/Shanghai` server. `Field.date` and `Field.time` - were affected the same way: stored verbatim on SQLite, refused outright on the live - dialects. - -The literal `'NOW()'` token now resolves on this door as it does on every other one; it -previously stored the four-character string `"NOW()"` into a datetime column on SQLite and -was refused by MySQL. - -#11176's `updated_at` stamping is unchanged in effect — the stamping decision now reads the -formatted payload, matching `update()` and `rotatedUpdateById`, and the stamp is still -applied afterwards as the literal post-map column name. diff --git a/.changeset/upload-session-organization-stamp.md b/.changeset/upload-session-organization-stamp.md deleted file mode 100644 index 006e6c1ade..0000000000 --- a/.changeset/upload-session-organization-stamp.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -fix(service-storage): stamp `sys_upload_session.organization_id` from the acting session (#12928) - -`StorageMetadataStore.createSession` inserted into `sys_upload_session` with no -execution context, so the SQL driver's `injectTenantOnInsert` had no `tenantId` -to stamp from and every chunked-upload session row landed with -`organization_id` NULL — on a tenancy-ENABLED object (the declaration carries no -`tenancy` key, so `applySystemFields` provisions the column unconditionally). -This is the `sys_upload_session` sibling of the `sys_file` gap fixed in #12745, -and the chunked-upload door already held the value: it threads the identical -`session?.organizationId` into the `createFile` immediately above. - -`createSession` now takes the same optional `StorageWriteContext` as -`createFile` and hands the engine `{ context: { tenantId } }`, so the platform's -existing insert-side chokepoint decides the rest — whether the object has a -tenant column at all, and whether an explicit value on the row wins. A caller -with no organization passes no options and the row lands unstamped exactly as -before. - -Maintainer ruling 2026-08-29, verbatim and untranslated: 「同意」 — forward stamp -only. There is deliberately **no backfill**: rows already NULL age out through -this object's own ADR-0057 TTL sweep. That premise is verified rather than -assumed — `sys-upload-session-ttl-sweep.test.ts` drives the shipped declaration -through the real `LifecycleService` against live SQL and pins that an expired -NULL-organization row is reaped, that a stamped row is reaped by the same -sweep, that a live session survives it, and that a run with no declaration reaps -nothing. - -Why an unstamped row mattered even without a cross-tenant read: both walled -Layer 0 predicates are exclusive (`{ organization_id: }` under `isolated`, -`{ $in: [...] }` under `group`), and neither matches NULL — so on a walled -deployment an unstamped session row was invisible to its own tenant, the same -silent-empty class `sys_api_key` was renamed to avoid. diff --git a/.changeset/user-actions-three-keys.md b/.changeset/user-actions-three-keys.md deleted file mode 100644 index 8c3d6b7b56..0000000000 --- a/.changeset/user-actions-three-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`UserActionsConfigSchema` adopts `group`, `hideFields` and `rowColor` (ruled Option A on objectui#5435): the three toolbar affordances ListView already honours become authorable in a spec-valid document, so the runtime fold of legacy `showGroup`/`showHideFields`/`showColor` flags now passes the save gate instead of being rejected by name. All three are booleans; the defaults copy the renderer's reads — `group` defaults on, `hideFields`/`rowColor` default off. Accept-set widening only: no existing document changes meaning, and the object-level `userActions` block (create/import/edit/delete/exportCsv) still rejects all view-vocabulary keys by name. diff --git a/.changeset/validate-json-failure-payload-warnings.md b/.changeset/validate-json-failure-payload-warnings.md deleted file mode 100644 index afa002e0de..0000000000 --- a/.changeset/validate-json-failure-payload-warnings.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os validate --json` carries the computed advisory lists on every failure exit, not the success payload alone (#12047) - -**Machine-contract widening on the `--json` failure payloads.** A consumer that -today branches on `warnings` being ABSENT from an `os validate --json` failure -payload, or that reads it as "the author-time rule advisories", sees a -different shape after this change. - -## What was wrong - -The text face prints its advisory blocks ending `— re-run with --json for the -full list`, but `warnings` lived on the TERMINAL SUCCESS payload only — plus -`ruleAdvisories` alone on two of the five failure exits. So the remedy the -notice named returned a payload that did not contain the list, and the author -could not reach the withheld entries by any route until an unrelated later -failure was fixed. - -The strongest instance is the parse-failure exit. `validate.ts` computes the -#3786 undeclared-authoring-key findings **before** the schema parse, precisely -so a finding survives an unrelated schema error — the parse is what strips the -key, so it cannot be recovered afterwards. That payload then dropped the list -anyway, defeating the one hoist that existed to prevent exactly this. - -## Which exits gain the field - -All five failure exits of `os validate --json`. Two already carried a partial -list; three carried none. `warnings` is now present on every one, alongside -each exit's existing keys, which are unchanged: - -| exit | existing keys | `warnings` before | after | -| --- | --- | --- | --- | -| protocol parse failure | `errors` | absent | undeclared-key findings | -| author-time rules failed | `errors` | `ruleAdvisories` | rule + key | -| capability provider check | `errors` | absent | rule + key + capability | -| package docs failed | `errors` | `ruleAdvisories` | rule + doc + key + capability | -| thrown / caught | `error` | absent | what the run had computed | - -The success payload is unchanged in content: its -`[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings]` -spread moved to a single `warningsSoFar()` site that every exit now reads, so -the member order cannot drift between exits. - -`structuralWarnings` is the one member `os validate` has that `os build` does -not, and it is **carried, not hoisted**: it is computed below all five failure -exits, so it rides each of them as an empty list and the success payload stays -the only exit that can ever show it non-empty. - -## What a consumer keying off its absence should do instead - -⛔ `warnings` is no longer a signal of which exit produced the payload. Read -`valid` (and `error` / `errors`) for that; a consumer that inferred "this is a -failure payload" from a missing `warnings` must switch to `valid === false`. - -⛔ `warnings` on a failure payload is no longer only the author-time rule -advisories. It is the same heterogeneous list the success payload publishes — -rule and doc findings as RECORDS, undeclared-key and structural advisories as -STRINGS — truncated to what the run had computed. A consumer that assumed every -entry was a rule finding must classify by shape. - -⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no -advisories". It means **this run stopped before those advisories were -computed** — a config that fails to load reports `[]` by construction. A -consumer that needs the full advisory set for a tree must read it from a run -that reaches at least the gate that computes it. - -✅ `warnings` is always an array on every `os validate --json` payload, success -or failure, so it can be read unconditionally — that shape constancy is the -point of the change (maintainer ruling 2026-08-25 on #11772, option 1 of three, -inherited here under the same-family rule; option 2, "carry them only where the -text face printed them", was rejected as the hardest contract to declare). - -Exit codes are untouched: every failure exit still exits 1, and `--strict` -still reads the text face's own list, so `os validate --json --strict` reaches -the same verdict it did before. - -Advisories stay CARRIED, never recomputed: each list is still computed at -exactly the step that owns it, so an exit upstream of a step legitimately -reports that list empty and no failure path pays for a computation it did not -already do. diff --git a/.changeset/validate-json-strict-exit-parity.md b/.changeset/validate-json-strict-exit-parity.md deleted file mode 100644 index 76427a0251..0000000000 --- a/.changeset/validate-json-strict-exit-parity.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os validate --json --strict` exits 1 on the configs `--strict` already exits 1 for (#11174) - -`commands/validate.ts` emitted the `--json` payload and `return`ed *above* the only -`flags.strict` reader, which sat inside the text-rendering block. So on one config, -one flag, two answers: - -``` -os validate --strict → exit 1 ("Strict mode: warnings treated as errors") -os validate --json --strict → exit 0 -``` - -`--strict` was accepted, documented — `content/docs/deployment/cli.mdx` spells -`os validate --json --strict` twice in its CI/CD section, once as a GitHub Actions -step — and inert whenever `--json` was also passed. That combination is the one -audience the flag exists for: a pipeline gating on the exit status of the documented -invocation read 0 and concluded the stack was clean. - -The `--strict` gate now reads the text face's own warning list, which is assembled -once and consumed by both faces, so the two exit codes cannot drift apart again. The -gate deliberately does **not** read the payload's `warnings` field: the two differ by -the ADR-0087 load-time conversion notices, which the text face folds into its warning -block while the payload carries them under `conversions`. Gating on the field would -have left the same divergence in place for a config whose only advisories are -conversion notices. `specVersionGap` stays outside `--strict` on both faces, as it -always has been on the text one. - -`valid: true` beside a non-zero exit is the text face verbatim, not a contradiction: -that path prints "Validation passed" and *then* fails for strict. The stack is -schema-valid; `--strict` is what promotes its advisories to a failure. - -**BREAKING** for one caller shape, and the reason this is not a patch: a pipeline -running `os validate --json --strict` over a stack that raises non-blocking -advisories was green and will now be red. Nothing was removed or renamed and no -authored metadata changes — the accept set is identical and the exit status is the -only thing that moves — but a release a CI system can take unattended must not flip -a green build to red, so this does not belong in a patch. It is not a major either: -the new behaviour *restores* what `--strict` declares ("Treat warnings as errors") -and what the docs already advertise, rather than contradicting a contract. Under -this repo's launch-window convention (breaking changes ship as `minor` while the -stack versions in lockstep) `minor` is the honest slot. - -## What a pipeline gating on the payload has to read - -`--strict` gates on the text face's warning list, and that list is **not** the payload's -`warnings` field. The two differ by the ADR-0087 load-time conversion notices: the text -face folds them into its warning block, while the payload carries them separately under -`conversions`. So a pipeline that wants to reproduce `--strict` from the document must -read **both**: - -``` -warnings.length > 0 || conversions.length > 0 -``` - -Gating on `warnings` alone is strictly weaker than `--strict` — a config whose only -advisories are conversion notices passes that check and fails `--strict`. That is the -same silent under-reporting this change exists to remove, so do not reach for the -narrower spelling. - -The consequence is reachable and worth stating outright, because it is surprising: a -conversions-only config now exits **1** with `"warnings": []` and a populated -`conversions`. Predicting the exit code from `warnings.length` alone will be wrong for -exactly that config. Nothing is missing from the document — both advisory streams are in -it — but they sit in two fields and the exit code answers to both. - -If a pipeline genuinely wants the old exit status, the honest fix is to say so rather -than to keep passing a flag that means the opposite: drop `--strict` and read the -payload. If it goes red instead, the advisories were always there — the text face had -been printing them all along. - - diff --git a/.changeset/validate-sweep-introspection-memo.md b/.changeset/validate-sweep-introspection-memo.md deleted file mode 100644 index 17c99bb613..0000000000 --- a/.changeset/validate-sweep-introspection-memo.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/service-datasource': patch -'@objectstack/spec': patch ---- - -`validateAll`/`validateDatasource` now read each datasource's live schema once per sweep instead of once per federated object: the sweep threads a per-call introspection memo through the validation body, so M objects on one datasource cost one remote introspection round-trip (a rejected read is shared the same way — one connection attempt, M failure rows). The memo lives and dies inside a single call, so a long-lived service never serves a stale schema to a later sweep, and direct `validateObject` calls still read live every time. The `IExternalDatasourceService.validateAll` docstring, which promised "parallelised per datasource" while the implementation parallelised per object, now states the actual behaviour. diff --git a/.changeset/value-roundtrip-conformance-case-set.md b/.changeset/value-roundtrip-conformance-case-set.md deleted file mode 100644 index 2941f1569e..0000000000 --- a/.changeset/value-roundtrip-conformance-case-set.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -test(spec,drivers): add the `VALUE_ROUNDTRIP` conformance case-set — "what you wrote is what you read back", enforced per driver per dialect (#12393) - -The driver-conformance census was green at 9 of 9 dialect-scored cells after -#12136 promoted `MATRIXED`, and **none of its nine case-sets was about value -storage**. All nine ask *which rows come back*; none asks *what is in them*. So -that green was not weak evidence about a round-trip defect — it was no evidence -at all, and it would have stayed green forever with the defect in place. That is -why this family kept arriving one card at a time: #12380 (SQLite's `Field.json` -codec was not injective), #11535 (a multi-value field read back as the string -`'["x","y"]'`), #11782 (MySQL answering `1`/`0` for a declared boolean), #10995 -(PG json values bound without `JSON.stringify`). - -`VALUE_ROUNDTRIP_CASES` closes it as a class rather than as a tenth instance. It -is 41 cases over five declared value classes — `json`, `multiple: true`, -`string`, `number`, `boolean` — and every value in it is one some driver was -**measured** to change, or a control that stayed faithful in the same -measurement. Assertions pin **type as well as value**: the before-state of every -card above was a wrong type carrying a right-looking value, which survives -`toEqual`-style coercion and every truthiness check. `VALUE_ROUNDTRIP_COLLISION_PAIRS` -adds the injectivity half a per-value check cannot see — a string and the native -value whose encoding it resembles must stay distinguishable. - -Enrolled through the census's existing machinery rather than as a bespoke suite, -which is the whole argument for this route: `CLASSIFIED` obliges the new fixture -to be named in `CASE_SETS`, `CONSUMED` obliges every driver to run it, and -`MATRIXED` obliges `driver-sql`'s cell to be answered on **every dialect it -speaks** rather than on SQLite alone — the coverage shape that let #12380 survive -in the first place. The census now reads **50 covered cells across 5 drivers × -10 case-sets, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt**. - -**No shipped behaviour and no public surface changes.** This is `@objectstack/spec`'s -`data` export gaining one conformance fixture, six new test files, and one -`CASE_SETS` row in the census script. No Zod schema, no runtime, no driver -source, no API. Graded `patch` for that reason: the package's published surface -grows by a test fixture that only conformance suites consume, and nothing an -existing consumer resolves changes shape. - -The one non-test change is a **test-double fidelity fix** the new case-set -surfaced: `driver-turso`'s `makeLibsqlSqliteStub` did not model `@libsql/client`'s -client-side boolean → `1`/`0` conversion, so a declared `boolean` written through -the REMOTE transport could not be bound at all. Verified against the dependency's -own source rather than the transport's comment; the transport is correct and -unchanged. diff --git a/.changeset/varchar-differ-expects-what-createcolumn-emits.md b/.changeset/varchar-differ-expects-what-createcolumn-emits.md deleted file mode 100644 index bd3a71a1e0..0000000000 --- a/.changeset/varchar-differ-expects-what-createcolumn-emits.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -fix(driver-sql): the varchar differ now expects what `createColumn` would actually emit, instead of a different rule (#12732) - -The managed-schema drift differ's varchar-length branch expected -`varchar(field.maxLength)` for any bounded field, over a **pre-existing** -column dialect `postgres`/`mysql` enforce. `SqlDriver.createColumn` does not -build that for every bounded field — and disagreed with the differ in two -measured directions: - -**An already-serving deployment stopped booting.** An UNKEYED, bounded -text-family field (`text` / `richtext` / `signature` / `markdown` / …) -reported `narrow_varchar` at severity `error`, category `destructive` — the -one category `runArtifactBootMigrationGate` refuses a boot for — demanding -the column be narrowed to a shape `createColumn` would never build: unkeyed, -it leaves the column TEXT (`keyableTextLength` returns `null` unkeyed). The -trigger was an ordinary, correct-looking edit: adding `maxLength: 50` to a -legacy `varchar(255)` text column. The divergence changed no behaviour at -all — the write seam already enforces the declared bound — so the refusal -was over nothing. - -**A `safe`, dev-auto-reconcilable finding planned DDL MySQL refuses -outright.** A base string-family field (`email` / `url` / `password` / …) -bounded past `SqlDriver.MAX_VARCHAR_CHARS` (16383) reported `widen_varchar` -at `warning`/`safe`, planning `ALTER … varchar(100000)` — `ERROR 1074 Column -length too big` on MySQL, while Postgres accepted it: the dialect-divergent -enforcement this package's conformance matrices exist to close. -`declaredVarcharLength` returns `null` above the ceiling for the same reason -`createColumn` never emits that DDL. - -This guard had already been patched at the call site three times for the -same defect class (#11431 for `multiple: true`; #11794/#11875 for genuine -TEXT columns) — each time by adding one more condition. This is that defect -arriving a fourth time, through a column spelled `varchar` because an older -release created it. Rather than a fourth patch, the branch now asks -`SqlDriver.varcharColumnChars(field, keyed)` — the emitter's own read-only -mirror of `createColumn`'s switch, already pinned against `columnInfo()` for -every `FieldType` — what width `createColumn` would actually build. `null` -means "the emitter would not make this a varchar," and the branch does not -fire. Keyedness (`indexedKeyColumns()`, #11374) is threaded from -`SqlDriver.detectTableDrift` into `diffManagedTable`, since a KEYED bounded -text-family field legitimately takes `varchar(maxLength)` — the fix -suppresses the false positive, not the branch itself; a keyed field over the -same shape still reports. - -Graded `minor` rather than `patch`, mirroring the sibling drift-op change for -#12121 in the opposite direction: an already-serving deployment that -currently fails to boot over Case A will boot after this upgrade, and a -`widen_varchar` currently eligible for dev auto-reconcile over Case B will no -longer be planned — both are user-visible behaviour changes for an existing -deployment (`os migrate plan`, `os migrate apply`'s counts, the boot-time -`[schema-drift]` warn), not merely an internal correctness detail. `diffManagedTable`'s exported args object gains two **optional** parameters -(`keyedColumns`, `varcharColumnChars`); omitting either keeps the pre-#12732 -behaviour unconditionally; this is additive to the object type and — unlike -#12121's `DriftOp` union member — does not add a case any consumer's -exhaustive switch must handle, so it is not itself a reason to grade higher -than `minor`. Nothing is removed, renamed, or newly rejected, so this is not -a breaking change. The category question (whether Case A's `destructive` -should become a report) is deliberately **not** addressed here: the fix -makes the false-positive stop firing entirely, so there is nothing left to -downgrade, and downgrading it as a separate act would be gate-weakening the -triage seat did not authorise. diff --git a/.changeset/view-conversions-reach-all-three-spellings.md b/.changeset/view-conversions-reach-all-three-spellings.md deleted file mode 100644 index d1acd6c691..0000000000 --- a/.changeset/view-conversions-reach-all-three-spellings.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Every view-family conversion now reaches all three persisted `view` spellings. - -`ViewMetadataSchema` accepts three body shapes and all three land in `sys_metadata` rows — the `defineView` container (`list`/`listViews`/`form`/`formViews`), the standalone ViewItem record (`{ viewKind, config }`), and the flattened runtime overlay (a raw ListView/FormView config at the top level plus its `object` + `viewKind` binding). Every view-family conversion walked only the container keys, so for the other two spellings the whole chain replayed by `applyConversionsToStoredItem('view', row)` was a no-op: a row written under an older protocol kept its historical shape while the conversion layer reported it canonicalized, and the rehydration parse then refused exactly what had never been rewritten. - -A new shared walker (`mapViewPayloads` in `conversions/walk.ts`) discriminates the three spellings using `ViewMetadataSchema`'s own discriminators — `viewKind` plus a `config` object for a record, the container slots for a container, `viewKind` with those slots absent for a flattened overlay — and hands each conversion the list/form payload wherever it lives, labelled with its family. All five view-family conversions adopt it: `view-visibleOn-to-visibleWhen`, `view-inert-keys-removed`, `view-list-passthrough-keys-removed`, `view-export-options-pdf-removed` and `form-view-option-default-removed`. - -The family label is load-bearing rather than informational: these conversions are shape-scoped, and two of them strip a key that is inert on one family and live on the other (`aria` is retired on a form and live on a list, `data` the reverse), so a walk that could not tell the two apart would delete live keys. - -No authoring surface moves and no accept set changes — this is data-at-rest canonicalization catching up to shapes the schema already ruled on. Container behaviour, including every notice path, is unchanged. diff --git a/.changeset/visiblewhen-describe-bound-roots.md b/.changeset/visiblewhen-describe-bound-roots.md deleted file mode 100644 index d726d79b90..0000000000 --- a/.changeset/visiblewhen-describe-bound-roots.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`visibleWhen`'s describe now states the roots it actually binds, split by what is contract and what is renderer behaviour. - -`PageComponentSchema.visibleWhen` named three roots (`record`, `current_user`, `page.`) while the shipping renderer binds nine. The describe now separates the two claims instead of widening one into the other: - -- **Contract-bound**: `record`, `current_user` — with ADR-0068's aliases `user` and `ctx.user`, one object under three spellings — and `page.`. These are transcribed from a ruling, not from the renderer: ADR-0068 D1 rules a predicate "evaluates identically in a formula, an RLS policy, and a client `visible` gate", and `EvalUser`'s docblock already states the same alias set for "client UI gates". The platform's own `sys_user` alert gate uses `ctx.user`, which the old describe implied was unavailable. -- **Renderer-provided, not guaranteed**: `app`, `features`, `os.user` and `data`. ADR-0068's Non-goals fence its ruling to the user object, so nothing rules these on this surface; they are recorded as measured behaviour rather than promised. -- **`data` is surface-dependent** and is now called out as such: the data-source **adapter** on a component node, the record **row** on a `page:tabs` item-level `visibleWhen`. - -The `page:tabs` item `visibleWhen` (`ComponentSchema`) carried the identical three-root sentence plus a "binds the same environment as page-component `visibleWhen`" claim that measurement disproves; it is corrected the same way and now names its two real divergences (row-bound `data`, bare-field spread). - -Describe/prose only — no accept/reject change, no shape change, no new keys. diff --git a/.changeset/walled-elevation-verified-email.md b/.changeset/walled-elevation-verified-email.md deleted file mode 100644 index 8e21fb2af3..0000000000 --- a/.changeset/walled-elevation-verified-email.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/plugin-auth': patch ---- - -Walled platform-admin elevation now requires the owner-email match to be -VERIFIED, and the bootstrap re-runs on the verifying update (#11343) - -Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched -the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on -`sys_user` — with no `email_verified` condition, while email verification is -off by default. #11211 narrowed elevation from "whoever registers first" to -"the declared owner's address" (a real and large narrowing); this closes the -remainder that card #11343 records: in the window before the owner registers, -an account created with the owner's address would still be elevated. - -Two halves, deliberately in one change: - -1. **The elevation match requires `email_verified`** (fail-closed allow-list - over driver representations; an absent field on an imported/legacy row - reads as unverified). An unverified holder of the owner's address is - refused like any stranger — new reason `walled_owner_not_verified`, logged - loudly with the unblock in the line. Never falls back, same direction as - the undeclared-owner refusal. -2. **The bootstrap-replay middleware now also fires on `sys_user` updates - touching `email_verified` / `email`** (trigger set extracted as - `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). - Verification is an UPDATE — with the old insert-only replay, requiring - verification would have refused the genuine owner at sign-up and then - never looked again, leaving the platform without any administrator. - -`single` posture is untouched both ways: first-user promotion (ruled -reasonable in #11184) does not gain a verification requirement, and the -owner-email variable is still never consulted there. Both directions are -pinned: the unverified holder is refused AND the verified owner is elevated — -including across the refuse-then-verify-then-re-run sequence. - -The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with -`email_verified` stamped: it is created by the deployment's own boot command -with operator-known credentials — the same trust shape as a trusted-SSO -insert, not an unknown self-registrant — so walled dev/harness boots keep a -promotable declared owner. The generic sign-up path is unchanged. diff --git a/.changeset/walled-owner-email-elevation.md b/.changeset/walled-owner-email-elevation.md deleted file mode 100644 index 23d4a5ea2e..0000000000 --- a/.changeset/walled-owner-email-elevation.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/plugin-auth": minor -"@objectstack/types": minor -"@objectstack/verify": patch ---- - -fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) - -**BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or -`isolated`), shipped as `minor` under the repo's launch-window convention for -breaking changes. Single-org deployments are byte-for-byte unchanged. - -Measured defect (cloud#1509): on a walled multi-tenant SaaS with -`OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the -FIRST self-registrant received the cross-tenant `admin_full_access` grant -(`platform_admin`, `isPlatformAdmin: true`) and — because the default-org -bootstrap binds "the platform admin" — was merged into the deployment's -Default Organization as its owner. Whoever curls the public sign-up endpoint -first owned the platform. - -Per the maintainer ruling of 2026-08-23 (verbatim: -「1509 选择 env 指定 owner 邮箱」): - -- **Walled postures: platform admin comes ONLY from the env-declared owner.** - `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest - human user when the requested posture is walled; it promotes exactly the - account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable - (case-insensitive, matched whenever that account registers — arrival order - is irrelevant). Self-registrants are never promoted and, since the shared - `ensureDefaultOrganization` helper binds only the platform admin, are never - auto-merged into the Default Organization either. -- **Fail-closed startup refusal.** A walled posture with no - `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` - with a message naming the variable — never a silent fallback to - first-registrant elevation. The elevation site itself also refuses - (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as - defense-in-depth for compositions that reach the bootstrap without - plugin-auth (`os meta resync`, bare embeddings). -- **Single-org posture unchanged.** "First user is owner" stays as ruled - reasonable there; the new variable is never consulted under `single`. -- The requested posture (`resolveTenancyPosture()`) is deliberately the input, - so a walled-requested deployment running degraded - (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. - -Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the -operator account's email address before upgrading. Deployments that already -hold a human platform admin are untouched (the bootstrap remains a no-op once -any human holds the cross-tenant grant); the variable governs installs that -have not yet minted their admin. `@objectstack/types` gains the -`resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` -constant; the verify harness declares the owner email (defaulting to its dev -admin) for walled fixtures. - - diff --git a/.changeset/walled-owner-no-verification-path-warning.md b/.changeset/walled-owner-no-verification-path-warning.md deleted file mode 100644 index fac59e5902..0000000000 --- a/.changeset/walled-owner-no-verification-path-warning.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -feat(plugin-auth): warn at boot when a walled deployment declares an owner it can never verify (#11640) - -A walled deployment (`OS_TENANCY_POSTURE=group|isolated`) that declares -`OS_PLATFORM_OWNER_EMAIL` but wires **no verification path** — no email -transport and no trusted federated sign-in — now emits a loud, named boot -warning (`walled_owner_no_verification_path`) on `kernel:ready`. - -Since #11343, walled platform-admin elevation requires the declared owner's -address to be **verified**, and verification can only arrive by an emailed -link or by a federated sign-in that inserts the account already verified. With -neither wired, the declared owner registers, is refused -(`walled_owner_not_verified`), and has no in-product way to satisfy the -condition — a dead end that previously surfaced only weeks later, at the -owner's rejected registration. The warning names both missing inputs and the -concrete wiring for either remedy (an email service, or SSO / a social -provider), since either one alone clears it. - -⛔ **Boot proceeds — this is not a refusal**, and no accept/reject behaviour -changes anywhere: the walled + undeclared-owner boot refusal (#11184) and the -fail-closed elevation refusal (#11343) are untouched. Deployments already -wiring either verification path see no new output, and neither does a -dev/harness boot whose declared owner is the dev-admin the seed provisions and -stamps verified. diff --git a/.changeset/walled-owner-operator-verified.md b/.changeset/walled-owner-operator-verified.md deleted file mode 100644 index 1ed3dff6fa..0000000000 --- a/.changeset/walled-owner-operator-verified.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/plugin-auth": minor -"@objectstack/types": minor -"@objectstack/plugin-security": patch ---- - -feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (#12751) - -On a **walled** deployment (`OS_TENANCY_POSTURE` in the wall-enforcing -family), the account whose email equals the declared platform owner -(`OS_PLATFORM_OWNER_EMAIL`) is stamped `emailVerified` **at creation** when -it comes into existence through an **operator provisioning path** — extending -the #11343 dev-boot seeded-admin precedent to production walled boots -(maintainer ruling 2026-08-28, cloud#1677: 「运营方创建即视为已验证」; the -trust anchor is the operator's env-var declaration plus the -operator-executed creation, not a mailbox round-trip; SMTP stays required -only for inviting others). - -**Which creation paths qualify** (the [#11739] audience taxonomy, not a -second classification): - -- the **bootstrap carve-out** — the very first account on a fresh install - (zero human users), the one self-serve creation a walled boot admits; -- **admin create-user / bulk import** (`method: 'admin'`) — an act only an - authenticated admin session can perform; -- **SCIM** (`method: 'scim'`) — provisioning executed by the - operator-registered directory. - -**Never**: non-bootstrap self-registration (including an -invitation-admitted registration typing the owner address), provider-class -JIT (the IdP asserts its own `emailVerified` at insert), any non-owner -address, any unwalled posture, and a later email **update** to the owner -address (the stamp is staged at the admission gate and consumed once by the -`user.create` before-hook — a seam an update cannot traverse). Dev-boot -behaviour (#11343) is unchanged. - -The `WALLED_OWNER_NO_VERIFICATION_PATH` boot warning now probes the owner -account's state: a fresh walled boot with no transport and no federated -sign-in is **silent** (the operator's own first-account creation arrives -verified — the case this closes), while an owner account that already -exists **unverified**, a populated store whose bootstrap window is spent, -and an unanswerable probe keep warning. A settled deployment whose owner is -verified stops re-warning on every boot. - -`@objectstack/types` gains `isEmailVerifiedUserRow` — the [#11343] -fail-closed verified-representation allow-list, moved from -`plugin-security`'s private copy so the elevation gate and the boot -diagnostic read ONE resolution (`plugin-security` now consumes it; no -behaviour change there). diff --git a/.changeset/walled-platform-bucket-diagnostic.md b/.changeset/walled-platform-bucket-diagnostic.md deleted file mode 100644 index bc91c77b05..0000000000 --- a/.changeset/walled-platform-bucket-diagnostic.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Stop the per-organization catalog pass from reporting the platform's own -permission sets as "pre-fix" leftovers with a remedy that recreates them - -On a fresh walled deployment (`OS_TENANCY_POSTURE=isolated`, three -organizations) the boot log warned, once per organization, that *"pre-fix -organization-less `sys_permission_set` rows are still present"* and offered -*"re-initialize the deployment, or adopt each row by hand"*. Both halves were -wrong there: - -- **Nothing was pre-fix.** The eight rows it named (`admin_full_access`, - `organization_admin`, `organization_admin_no_bypass`, `member_default`, - `viewer_readonly`, `mcp_agent_data_read`, `mcp_agent_data_write`, - `mcp_agent_restricted`) were minted 1.3 s earlier — before the deployment's - first organization existed — by `bootstrapPlatformAdmin`, the fifth seeder, - which the #10103 ruling deliberately left outside the per-organization - conversion. An operator on a deployment hours old was told they were carrying - legacy state they never had. -- **Its first remedy did not terminate.** Re-initializing a fresh walled - deployment mints exactly those eight rows again on the next boot, so only the - hand-adoption branch ends — and that one hands a platform-wide bucket to a - single tenant. - -The pass now separates the two classes it was conflating and reports each with -the remedy that fits, carrying a machine-readable `origin` -(`'platform-bucket'` / `'pre-fix-residue'`) beside the named rows: - -- the **platform bucket** — names an organization-less writer still seeds on - every boot — is reported as what it is, states that this organization's own - copies were created and no action is required, and says plainly that - re-initializing does *not* clear it; -- a **genuine pre-fix leftover** keeps the original wording and the original - remedy, unchanged. - -Membership is decided by name rather than by `managed_by`, because the question -the remedy turns on is "will a re-initialized deployment have this row again?" -— true for these names whatever provenance the current row carries (a -pre-#8692 install stores `'admin'` on the very same names). It falls back to the -shipped `defaultPermissionSets`, so a host that never threads the new -`platformBucketNames` option still classifies correctly; the option exists for -a host that overrode `SecurityPluginOptions.defaultPermissionSets`. - -`bootstrapPlatformAdmin` also declares what it wrote: under a walled posture it -now logs that the platform defaults were seeded *without* an organization and -that each organization's copies come from the catalog pass. The rig's boot line -read `{"seeded":8}` with nothing to indicate the rows carried no organization -at all, so the operator's first sight of them was the warning above. - -**No behaviour change to the seeding itself.** The eight rows are still minted, -still organization-less, still unreaped — that is the ruled outcome of #10103 -(2026-08-20), and `PLATFORM_ADMIN` is derived from an unscoped grant pointing at -the `admin_full_access` row *by row id*, so removing them would silently demote -every platform admin. Whether the platform bucket should be materialized per -organization remains the maintainer's open call, not this change. diff --git a/.changeset/warm-pumas-repeat.md b/.changeset/warm-pumas-repeat.md deleted file mode 100644 index f6a5bcbedf..0000000000 --- a/.changeset/warm-pumas-repeat.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os migrate plan` / `os migrate apply` now diff the object set the deployment actually serves - -Both commands booted `createStandaloneStack` and nothing else, so on any real deployment they examined a five-table subset — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit`, `sys_metadata_history`, `sys_view_definition` — and reported `0` drift over it. The host `objectstack.config.ts` was never loaded (the standalone stack says so itself) and no platform plugin was composed either: only the DATA subcommands reached `PlatformObjectsPlugin`, through `buildDataMigrationPlugins`. - -That failed in the direction that reads as success. With nothing registered there is no drift, so `plan` printed *"Physical schema is in sync with metadata — nothing to migrate."* — while the driver's own boot-time detector, running with the full registered object set, reported findings on the same database whose message ends `run "os migrate apply"`. Measured against a control plane carrying roughly eighty `sys_*` tables: ten boot-time findings, five tables examined, "in sync". - -`plan` and `apply` now compose what `os serve` composes: the host config's plugins (plus `AppPlugin(config)` when the config carries top-level metadata and brings no app plugin of its own), and `PlatformObjectsPlugin` — the one plugin `serve` injects unconditionally. Both commands compose identically, so the plan an operator reads and the set `apply` reconciles are the same. - -Nothing about what counts as drift changed. A plan that now reports findings it used to hide is the fix working. - -Two behaviours worth knowing: - -- **Host plugins are composed for their DECLARATIONS only** — `init()` runs, `start()` does not. `os migrate plan` is a declared dry run, and host plugins are arbitrary code: composed fully, `SecurityPlugin` alone attempted fourteen inserts into `sys_permission_set` during a plan, from its `start()` bootstrap. The kernel contract puts object declarations in `init()`, which is all a schema command needs. The residue: a plugin that registers its objects in `start()` instead of `init()` stays outside the plan. -- **A project with neither an `objectstack.config.*` nor a compiled artifact is unchanged** — five tables, same output, same `--json` document. There is no deployment there to mirror. - -A host config that exists but fails to load (a missing environment variable is the common case) is reported loudly on stderr and does not fail the command; `os migrate plan --json` then carries `composition.hostConfigLoaded: false`, because the table count alone cannot tell that apart from a deployment that is genuinely small. diff --git a/.changeset/watcher-unlink-requires-absence.md b/.changeset/watcher-unlink-requires-absence.md deleted file mode 100644 index 5fcfde9245..0000000000 --- a/.changeset/watcher-unlink-requires-absence.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/metadata-fs': patch ---- - -The file watcher no longer publishes a `delete` for an item that is still on disk. - -A watcher `unlink` is a claim of absence, not absence: chokidar reaches its removal path from failed stats as well as from real removals, so under filesystem pressure it can retire a file that is still there. `FileSystemRepository` published those claims straight through as `delete` events — appended to the change log and broadcast to every subscriber, which drops the item from the metadata registry and the `list()` cache — and the reconciliation sweep then republished the untouched file as a `create`. A failed stat therefore produced a durable delete/create pair for an item nobody removed, with a window in between where live metadata had disappeared. - -The removal face now confirms the absence against the disk under the same per-key lock the reconciliation sweep already used for this, and an `unlink` for a path that still exists falls through to the content comparison — so a spurious unlink that arrived alongside a real external edit surfaces as the `update` it always was. Genuine external removals are unaffected and are still published on the first delivery. diff --git a/.changeset/where-id-predicate-conflict-refusal.md b/.changeset/where-id-predicate-conflict-refusal.md deleted file mode 100644 index 130c9ce5ac..0000000000 --- a/.changeset/where-id-predicate-conflict-refusal.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor ---- - -**BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230). - -`update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes. - -What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict): - -- A truthy scalar `data.id` beside a **non-scalar** `where.id` — an operator object, an array, `null`, or an explicitly-`undefined` `id` key — now **throws** `UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with both declarations silently ignored. -- Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no** `id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm. -- The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered. - -A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations. - - diff --git a/.changeset/wild-pears-remain.md b/.changeset/wild-pears-remain.md deleted file mode 100644 index dc16c2aae3..0000000000 --- a/.changeset/wild-pears-remain.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/service-automation": patch -"@objectstack/objectql": patch ---- - -Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name - -A runtime-authored flow that reused a packaged flow's name silently replaced it, -and which of the two ended up armed depended on registration order. The metadata -registry keys items `packageId:name` and deliberately coexists both (ADR-0048 -§3.4), `listItems('flow')` returns both with no precedence, and the automation -engine keys flows by bare name — so the boot pull registered both under one key -and Map iteration order picked the survivor. Measured: registering the package -first armed the runtime flow, registering the runtime row first armed the -packaged flow, with no warning and no way to tell which had won. - -The boot pull now collapses same-named definitions before anything is armed, -applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the -runtime/DB overlay wins over the packaged artifact, which is the sanctioned -override path. Two packages shipping one bare name resolve by package id, so -boot order no longer decides anything. - -Collisions are no longer silent. The pull warns once per colliding name — naming -the name, every contender, and which one is armed — and repeats it at bootstrap -beside the other automation audits. `getShadowedFlows()` is a new receipt listing -each contested name with its armed and shadowed definitions, and -`getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested -names; previously the displaced definition was invisible by construction, since -the flow map holds one entry per name. The `Pulled N flow(s)` line now counts -distinct names rather than registrations. - -`isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that -collapse same-named metadata answer "does a code package ship this?" with the -registry's own test instead of re-deriving it from `_packageId`. diff --git a/.changeset/wild-poems-repeat.md b/.changeset/wild-poems-repeat.md deleted file mode 100644 index d3de8bd322..0000000000 --- a/.changeset/wild-poems-repeat.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Strip internal issue-tracker ids from the customer-facing text in `packages/spec` - -Refusal prose, unknown-key guidance, tombstone prescriptions and `.describe()` -docs no longer carry `#NNNN` references. A customer reading a rejected-metadata -error — or the generated reference docs — has no access to this repo's tracker, -so an id there is a citation-shaped token that resolves to nothing in the one -place the sentence most needs to be actionable. - -**FROM → TO, at bucket level** (584 strings across 88 spec sources): - -| bucket | strings | FROM | TO | -|---|---:|---|---| -| `strictObject` guidance / history / aliases | 262 | ``Until #4001 closed this shape these were dropped silently`` | ``Until this shape was closed these were dropped silently`` | -| `retiredKey()` tombstones + `*_RETIRED_KEY_GUIDANCE` | 130 | ``removed in @objectstack/spec 17 (#3894) — use `skills`.`` | ``removed in @objectstack/spec 17 — use `skills`.`` | -| `.describe()` docs prose | 187 | ``Parsed but no runtime consumer yet (liveness #1878/#1893).`` | ``Parsed but no runtime consumer yet.`` | -| zod `message:` (hoisted spelling) | 5 | ``a pair that cannot work as written (#9041).`` | ``a pair that cannot work as written.`` | - -**Kept, deliberately:** ADR ids, protocol and package versions, error codes, and -the `os migrate meta --from ` migration commands. AGENTS.md requires a -tombstone prescription to carry a durable reference, and those are the forms a -customer can actually resolve — the issue id riding beside them was the -strippable half. - -No behaviour changes: no schema accepts or rejects anything it did not before, -and no key, default or error `code` moved. This is the wording of messages and -generated docs only. diff --git a/.changeset/wild-pugs-shave.md b/.changeset/wild-pugs-shave.md deleted file mode 100644 index 134aa24699..0000000000 --- a/.changeset/wild-pugs-shave.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Reference docs: qualify a union variant's `### Nested Shape:` and `### Allowed Values:` headings by which variant they belong to. - -The `### Union Options` renderer calls the property-table renderer once per variant, and both halves of the `Schema.key` qualifier those headings carry are shared by every sibling variant of one schema. Two `ViewItem` variants each declaring a shape-opening `config` therefore emitted `### Nested Shape: \`ViewItem.config\`` twice — two identical anchors on one page, on the very heading whose qualifier exists to prevent that. Measured on the published tree: 12 excess occurrences, 10 distinct headings, 4 pages, all under schemas rendering `### Union Options`. - -The heading now names the variant with the accessor grammar the page already prints — `ViewItem[viewKind='list'].config`, or `[option 2]` where the union pins no distinct discriminant — reusing the same `variantSelector` that stamps the variant segment into a property accessor, so a page carries one variant notation rather than two. `### Allowed Values:` is covered in the same change (no page collides there today; the exposure is identical). Headings outside a union variant, and unions with a single heading-emitting arm, are byte-identical: regenerating the 214-page tree changes 39 heading lines and nothing else. - -This changes what the docs site publishes, hence a release-visible patch rather than a skipped changeset. diff --git a/.changeset/write-path-refusal-code-parity.md b/.changeset/write-path-refusal-code-parity.md deleted file mode 100644 index e801df08dc..0000000000 --- a/.changeset/write-path-refusal-code-parity.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Serve the `code` a sandboxed hook declared on `/api/v1/data` refusals, at every -status — not only where a bespoke arm happened to catch the condition first - -Measured on a booted 17.1.0 server: a hook throwing -`Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })` reached -the client as `409 {"error":"…","object":"crm_opportunity"}` — the status but no -machine-readable `code`. Same for `DUPLICATE_VALUE` on `POST` and `FORBIDDEN` on -`403`, while `DELETE_RESTRICTED` at the same 409 and `VALIDATION_FAILED` at 400 -carried theirs. A client that must tell "this record is frozen, do not retry" -from "this value is already taken, offer a merge" got `409` for both and had to -substring-match prose that is localised and deliberately reworded over time — -the failure mode the ADR-0112 `code` vocabulary exists to remove. - -The branch is `classifyDataError`'s **sandbox unwrap door** in -`error-response.ts` (`typeof error?.innerMessage === 'string'`), which rendered -from the raw error and emitted no `code` at all, while every arm around it -renders from the resolved envelope. That is the whole of the reported -correlation between "no `code`" and "the unwrapped message": they are one -branch, not cause and effect. - -It was never the status policy it looked like from outside. The door dropped -`code` on a declared **400** exactly as on a declared 409, and kept it on a -declared 5xx by falling through to the passthrough below — one sandboxed -producer, its code surviving 503 and lost at 409. What made the reading look -status-shaped is which codes have a bespoke arm above the door: -`DELETE_RESTRICTED` and `VALIDATION_FAILED` do and never reach it, -`RECORD_LOCKED` / `DUPLICATE_VALUE` / `FORBIDDEN` do not and did. - -The code now rides via `thrownCodeFields`, the one definition the three sibling -arms already use, so the door joins the closed ADR-0112 vocabulary: a registered -spelling arrives verbatim, an unregistered one is demoted to `declaredCode` -beside the status-derived member. Nothing is invented — a producer that declared -no code still gets a body carrying none. - -Unchanged: the business message is still the unwrapped `innerMessage` (never the -`hook 'x' threw: …` debug wrapper), a crashing hook body is still the sanitised -`500 INTERNAL_ERROR`, a declared 5xx still withholds its prose, and a refusal -that declares no status still answers 400. diff --git a/apps/docs/CHANGELOG.md b/apps/docs/CHANGELOG.md index 20b01dc4f8..500a91149b 100644 --- a/apps/docs/CHANGELOG.md +++ b/apps/docs/CHANGELOG.md @@ -1,5 +1,110 @@ # @objectstack/docs +## 4.2.3 + +### Patch Changes + +- 18b6d89: build(docs): stop emitting the 348 MB of server source maps the OOM-killed build was paying for (#12711) + + Every `objectstack.ai` production deploy on 2026-08-27 died with `exit 137` and + Vercel's `errorCode: "out_of_memory"` on a 4-core/8192 MB build machine. The + build now declares `experimental.turbopackSourceMaps: false`, which suppresses + 260 map files totalling 348 MB that no production serverless function reads. + + The knob matters because of **where** the kill lands. Every failing log places it + between `Creating an optimized production build ...` and `Compiled successfully`, + with no output in between — inside the Turbopack compile phase, which the two + knobs already present cannot reach: + + - `experimental.cpus: 2` bounds static-generation workers that have not spawned + yet when the process dies. + - `NODE_OPTIONS=--max-old-space-size` bounds V8's old space, while Turbopack + allocates from Rust outside it. + + Measured on a local cold build of all 403 pages, as peak single-process RSS: + + | config | peak | compile | + |---|---|---| + | before | 5191 MB | 22.6s | + | **`turbopackSourceMaps: false`** | **4757 MB** | **19.5s** | + | `turbopackScopeHoisting: false` | 4806 MB | 19.1s | + | `--max-old-space-size=2048` | 4734 MB | 18.0s | + | `turbopackFileSystemCacheForBuild: true` | 4779 MB | 22.2s | + + Only the first row moves anything; the rest are noise, which is the measurement + that retires them as candidates rather than leaving them to be re-tried. + + Set explicitly on purpose. Next documents this flag's build-time default as + following `productionBrowserSourceMaps` (false), but the server-side maps are + emitted regardless — naming it is what suppresses them. + + Deliberately not paired with `turbopackMinify: false`, which takes a further + 972 MB off the peak (3785 MB) and 3s off the compile: it inflates client JS from + 5.8 MB to 16 MB (+176%), a cost every reader pays on every visit to save memory + in a machine they never touch. Confining minification to the server side — where + the memory actually goes, 589 MB of server chunks against 5.8 MB of client — is + not available: `experimental.serverMinification` is read only by + `dist/build/webpack-config.js`, never on the Turbopack path. + + No output change beyond the absent maps: same routes, same 1221 prerendered + paths, same rendered bytes. + + **This buys margin; it does not prove the ceiling is cleared.** The measurement + above is macOS/arm64 and the build container is Linux/x86_64, where the same + phase was measured at 7.6 GB (#12683) against local 4.7 GB — the ratio is the + transferable part, and −8.4% onto 7592 MB leaves roughly 15% headroom, which is + thin. #12683's option A (a larger build machine) is unaffected by this change and + remains the answer if the next production build still dies. +- 168941c: build(docs): stop rebuilding the docs site on every push to `main` (#12743) + + Every push to `main` rebuilt the documentation site, and almost none of them + changed what it renders. Measured over one week across the team: + + | project | production builds | build-minutes | avg | + |---|---|---|---| + | **objectstack (docs)** | **228** | **2835 (98.6%)** | **12.4 min** | + | objectui | 123 | 36 | 0.3 min | + | hotcrm | 42 | 5 | 0.1 min | + + The team runs `concurrentBuilds: 1`, so an 18-second `objectui` build queued + behind a 12–46 minute docs build; the queue reached **92 deployments, the oldest + 34 hours old**. At 4 vCPU those docs builds cost roughly **$171/month** against a + $20 included allowance, and 168 of the 228 were failures, so most of it bought + nothing. + + `apps/docs/vercel.json` now declares an `ignoreCommand` (which overrides the + dashboard's Ignored Build Step, moving the rule into version control where it is + reviewable and revertible). `scripts/vercel-ignore-docs.sh` decides: + + 1. non-production → skip (unchanged from the rule it replaces) + 2. `content/**` or `apps/docs/**` changed → build + 3. otherwise → ask turbo whether the docs dependency graph is affected + 4. anything indeterminate → **build** + + **Step 2 is not redundant with step 3, and dropping it would silently stop + publishing documentation.** `turbo --filter=...[range]` computes affected + packages *by package directory*, and this repo's MDX lives at the repo root in + `content/`, outside the `apps/docs` boundary. `turbo.json` does list + `"$TURBO_ROOT$/content/**"` under `@objectstack/docs#build`'s `inputs`, but + `inputs` only feeds the cache hash — it does not widen the affected-package + calculation. Verified on `main`: commit `1265f12b` touches only + `content/docs/api/client-sdk.mdx`, and a dependency-graph check alone answers + SKIP for it. + + The asymmetry in step 4 is the point. A wrong "build" costs a few build-minutes; + a wrong "skip" leaves the site quietly stale with no error anywhere. So a missing + `VERCEL_GIT_PREVIOUS_SHA`, a shallow clone that cannot reach it, an unparseable + turbo verdict, and a non-0/1 exit from turbo all build. + + Deliberately not `npx turbo-ignore`, which #12698 suggested: it is deprecated + upstream ("Use `turbo query affected` instead") and derives its own comparison + range, falling back to `[HEAD^]` when it cannot read Vercel's git environment — + silently answering a different question than the one asked. The range is named + explicitly here instead. + + `scripts/vercel-ignore-docs.selftest.sh` pins all six cases against real commits + from this repo's history, including the `content/**` one. + ## 4.2.2 ### Patch Changes diff --git a/apps/docs/package.json b/apps/docs/package.json index 6928d4d639..ada7580f53 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/docs", - "version": "4.2.2", + "version": "4.2.3", "private": true, "description": "ObjectStack Protocol Documentation Site", "license": "Apache-2.0", diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 1ed1ce73e1..0133fcca3b 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.2.0 + ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 + ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 +FROM ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 +RUN npm install -g @objectstack/cli@17.3.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 510d215b40..77533b9404 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -38,7 +38,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.2.0 +image: ghcr.io/objectstack-ai/objectstack:17.3.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 041d537695..a360bfe4f7 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.2.0 +FROM ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 + ghcr.io/objectstack-ai/objectstack:17.3.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -62,5 +62,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.2.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.3.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index aeb721d311..1a7360ef07 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/example-crm +## 4.0.95 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [7ef0268] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c68c670] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + ## 4.0.94 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 36d9602e52..52e8f9f7e7 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.94", + "version": "4.0.95", "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-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index b17ee6edd2..85c03df093 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,229 @@ # @objectstack/example-showcase +## 0.3.17 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [7ef0268] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [eae824e] +- Updated dependencies [178f90c] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [84de7e3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [f9ffd01] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [34d3011] +- Updated dependencies [9d3c04d] +- Updated dependencies [d29e42f] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [3f42920] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [ebcc34e] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [12e306a] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [107bb4b] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c68c670] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [431d2fb] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [6c6157a] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [7986d97] +- Updated dependencies [1c66fe4] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [709ce17] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [cd13488] +- Updated dependencies [df1c75c] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [3956069] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [9981f31] +- Updated dependencies [d028b37] +- Updated dependencies [c49afd0] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [1246b4c] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/cloud-connection@17.3.0 + - @objectstack/connector-mcp@17.3.0 + - @objectstack/connector-openapi@17.3.0 + - @objectstack/connector-rest@17.3.0 + - @objectstack/connector-slack@17.3.0 + ## 0.3.16 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 14e8337712..7b3ddb7abd 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.16", + "version": "0.3.17", "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 e9e32fd1c9..ad218acd5a 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,222 @@ # @objectstack/example-todo +## 4.0.95 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [87042b5] +- Updated dependencies [40a93b5] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [32448d4] +- Updated dependencies [54e2d36] +- Updated dependencies [7ef0268] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [b15d260] +- Updated dependencies [887b97d] +- Updated dependencies [d9aa041] +- Updated dependencies [7899f57] +- Updated dependencies [6274a1a] +- Updated dependencies [1fa05a6] +- Updated dependencies [22c42c9] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [1524927] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [4ceae8a] +- Updated dependencies [48318f7] +- Updated dependencies [cf71d73] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8649b39] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [3519f8d] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/client@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/mcp@17.3.0 + - @objectstack/driver-sqlite-wasm@17.3.0 + - @objectstack/knowledge-memory@17.3.0 + - @objectstack/service-knowledge@17.3.0 + ## 4.0.94 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index f5efb426c6..0e20505d1f 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.94", + "version": "4.0.95", "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 5c6f39ce56..681d5df943 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,186 @@ # @objectstack/example-embed-objectql +## 0.0.35 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [aa3f9ba] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c5b9ccc] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/driver-memory@17.3.0 + ## 0.0.34 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 7aeb7d194e..476208f119 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.34", + "version": "0.0.35", "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 c216a565da..2719437a95 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,40 @@ # @objectstack/hono +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [39d625f] +- Updated dependencies [7ef0268] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [23843d3] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [e7dfb1d] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [e5ce2ed] +- Updated dependencies [c68c670] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [fb5fbb8] +- Updated dependencies [6a571d3] +- Updated dependencies [7986d97] +- Updated dependencies [735f5c7] +- Updated dependencies [dc75ba8] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [9981f31] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] + - @objectstack/runtime@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 2c058107f6..a24d20a546 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.2.0", + "version": "17.3.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 ddec04a5a6..a74e590f20 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/account +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 4c13c1b396..f84317e009 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.2.0", + "version": "17.3.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 b05040bc77..5225db8449 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/setup +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index fcf250b258..8a664845d6 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.2.0", + "version": "17.3.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 d9de273284..340430345e 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/studio +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 23f587d4c5..09315e787f 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.2.0", + "version": "17.3.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 4b56decb6d..7a8d5c08f4 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,2545 @@ # @objectstack/cli +## 17.3.0 + +### Minor Changes + +- 87042b5: feat(client,cli)!: `client.projects.*` becomes `client.environments.*`, the scoped sub-client becomes `ScopedEnvironmentClient`, and the unwrap keys follow the wire (#12866, #12882, ADR-0006 D2) + + + + + **BREAKING** public-API rename on `@objectstack/client`, and a breaking change to + the `--format json` payload of the `os environments` command family. It lands + after the v17.0.0 cut, so the lockstep launch-window convention ships it as + `minor` (`scripts/check-changeset-no-major.mjs`); the version number is not the + migration signal here, this entry is. + + This is the **SDK half** of one coordinated cross-repo rename. The **producer + half** is the cloud control plane, which renames the same field keys on the same + endpoints. Neither half ships alone: shipping the SDK half by itself is + ADR-0006 D3, permanently declined, as is any mapping layer between the two + spellings. + + ## Migration + + **No aliases exist.** The old namespace is gone, not deprecated — there is no + `client.projects` getter, no `res.project ?? res.environment` hedge, and none is + coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule + 「no aliases」 is the standing one). Every call site moves in one edit. + + ### Method namespace + + | before | after | + | --- | --- | + | `client.projects.list(…)` | `client.environments.list(…)` | + | `client.projects.get(id)` | `client.environments.get(id)` | + | `client.projects.create(req)` | `client.environments.create(req)` | + | `client.projects.update(id, patch)` | `client.environments.update(id, patch)` | + | `client.projects.delete(id, opts)` | `client.environments.delete(id, opts)` | + | `client.projects.activate(id)` | `client.environments.activate(id)` | + | `client.projects.rotateCredential(…)` | `client.environments.rotateCredential(…)` | + | `client.projects.updateHostname(…)` | `client.environments.updateHostname(…)` | + | `client.projects.updateVisibility(…)` | `client.environments.updateVisibility(…)` | + | `client.projects.listRevisions(…)` | `client.environments.listRevisions(…)` | + | `client.projects.listBranches(id)` | `client.environments.listBranches(id)` | + | `client.projects.renameBranch(…)` | `client.environments.renameBranch(…)` | + | `client.projects.deleteBranch(…)` | `client.environments.deleteBranch(…)` | + | `client.projects.retryProvisioning(id)` | `client.environments.retryProvisioning(id)` | + | `client.projects.listDrivers()` | `client.environments.listDrivers()` | + | `client.projects.packages.*` | `client.environments.packages.*` | + + The URL paths are unchanged — they were already on the `environments` spelling + (`/api/v1/cloud/environments/…`). Only the method namespace and the response + field keys move. + + ### Response keys + + | before | after | where | + | --- | --- | --- | + | `res.projects` | `res.environments` | `list` (the `total` key is unchanged) | + | `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` | + + The joined blocks on `get` (`database`, `credential`, `membership`, + `organization`) keep their names, as do every `packages.*` key, the + `delete`/`listBranches`/`renameBranch`/`deleteBranch` payloads (already + `environmentId`-keyed), and `listRevisions`. + + ### Two declarations that were false before this change + + Measured 2026-08-28 against the cloud repo's `main`, and corrected here rather + than carried forward under a new spelling: + + - **`create` never answered a `project` key at all.** `POST /api/v1/cloud/environments` + has always answered `{ environment, warnings, durationMs, hostnameAssignment? }`. + The old `{ project: any; database: any }` declaration was not merely + pre-rename, it was wrong against the running control plane — and + `os environments create` read `res.project.id` through it, so the default + `--activate` silently never activated and the table output printed + `undefined`. Both are fixed by this rename. + - **`create` declares no `database` key.** That route does not send one; the key + was declared NON-optional, so `res.database.driver` typechecked and threw. The + method that really answers a `database` block is `get`, which keeps it. + + The keys `create` does send beside `environment` (`warnings`, `durationMs`, + `hostnameAssignment`) are deliberately still undeclared — adding them is new + published surface and a separate decision. + + ### The environment-scoped sub-client (#12882) + + The fourth `project`-spelled surface on the same class, folded in by the same + maintainer ruling. ADR-0006's D1 census named three surfaces and missed this one; + it was an oversight, not a deliberate retention. + + | before | after | + | --- | --- | + | `client.project(id)` | `client.environment(id)` | + | `ScopedProjectClient` (exported class) | `ScopedEnvironmentClient` | + + Same no-alias rule: neither old spelling survives. `client.project(id)` is not a + deprecated method, it is gone, and the exported class is gone under its old name + — a `import { ScopedProjectClient }` fails at the import line, which is the + loudest and most precise channel this change has. + + Nothing about the behaviour moves: the scoped client still prefixes + `/api/v1/environments/:environmentId/...`, still exposes the same `data` / `meta` + / `batch` / `packages` shape, and the thrown guard message becomes + `[ObjectStack] environment(id): environmentId is required`. + + **Deliberately NOT renamed, because each is a different surface needing its own + decision:** `setProjectId` / `getProjectId` on the client — `getProjectId` is a + cross-package protocol contract that `packages/runtime` and + `packages/metadata-protocol` both speak, so it is a coordinated rename, not a + local one — and the REST API config keys `enableProjectScoping` / + `projectResolution`, which are live keys read by `packages/cli/src/commands/serve.ts`. + The docblocks that name them are worded so they stay true. + + Note for whoever compiles the release notes: four other pending changesets in + this release describe methods on `ScopedProjectClient` under its old name + (`client-unannotated-return-erasure`, `client-saveitem-ifmatch-header`, + `client-meta-saveitem-query-options`, `client-precise-sdk-return-types`). They + were accurate when written and are deliberately left alone; this entry is the one + that renames the class. + + ### JSDoc + + The `create` docblock claimed the server delegates to + `ProjectProvisioningService.provisionProject`. That spelling has zero hits in the + control plane (measured 2026-08-28). Both this SDK's docblock and + `os environments create`'s now name the **endpoint** instead, which is the one + identifier an in-repo reader can verify — the class lives in a repo this one + never compiles against, so no gate here could ever have caught the rot. + + ## CLI + + `os environments list | show | create | switch | bind` follow the same rename. + No flag, argument, exit code or command id changes. `--format json` / `--format yaml` + payloads are `formatOutput(res, …)` straight from the control-plane response, so + their top-level keys change with the wire: a script reading `.projects` or + `.project` from those payloads reads `.environments` / `.environment` instead. +- d114d5e: feat(cli): `os build --json` carries the computed advisory lists on every failure exit, not the success payload alone (#11772) + + **Machine-contract widening on the `--json` failure payloads.** A consumer that + today branches on `warnings` being ABSENT from an `os build --json` failure + payload sees a different shape after this change. + + ## What was wrong + + The text face prints its advisory blocks before the gates that can stop the + run — the #11529 author-time advisories at step 3b, the #3786 undeclared + authoring-key findings at 3d — and both end in `— re-run with --json for the + full list`. But `warnings` lived on the TERMINAL SUCCESS payload only (plus, + for `ruleAdvisories` alone, the author-time-rules failure). On a tree with 60 + undeclared authoring keys *and* a package-docs error: + + ``` + os build Undeclared authoring keys (60) … 50 rows … + … and 10 more … — re-run with --json for the full list + os build --json {"success":false,"error":"docs validation failed","issues":[…]} + ^ the 60 keys nowhere + ``` + + The remedy the notice named returned a payload that did not contain the list, + and the author could not reach the withheld entries by any route until an + unrelated later failure was fixed — the "the remedy named is unreachable" + shape of #11643 and #11391. + + ## Which exits gain the field + + All nine failure exits of `os build --json`. Six already had a payload of their + own; three more were found while enumerating (the filing card's table listed + six). `warnings` is now present on every one, alongside each exit's existing + keys, which are unchanged: + + | exit (step) | existing keys | `warnings` before | after | + | --- | --- | --- | --- | + | `strict-body: missing body` (2b) | `issues` | absent | `[]` | + | protocol parse failure (3) | `errors` | absent | `[]` | + | `author-time rules failed` (3b) | `issues` | `ruleAdvisories` | unchanged | + | `capability provider preflight failed` (3c) | `issues` | absent | rule + capability | + | `access matrix drift` (3e) | `changes` | absent | rule + key + capability | + | `docs validation failed` (3f) | `issues` | absent | all four lists | + | `--no-runtime-bundle` refusal (4b) | `error` | absent | all four lists | + | `runtime bundle failed` (4b) | `error` | absent | all four lists | + | thrown / caught (bottom) | `error` | absent | what the run had computed | + + The success payload is unchanged in content: its + `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings]` + spread — `os validate --json`'s order minus its trailing `structuralWarnings` + — moved to a single `warningsSoFar()` site that every exit now reads, so the + member order cannot drift between exits. + + ## What a consumer keying off its absence should do instead + + ⛔ `warnings` is no longer a signal of which exit produced the payload. Read + `success` (and `error` / `errors`) for that; a consumer that inferred "this is + a failure payload" from a missing `warnings` must switch to `success === false`. + + ⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no + advisories". It means **this run stopped before those advisories were + computed** — the two early exits above (`strict-body`, protocol parse) run + before any advisory step, so their list is empty by construction. A consumer + that needs the full advisory set for a tree must read it from a run that + reaches at least the gate that computes it, or from `os validate --json`. + + ✅ `warnings` is always an array on every `os build --json` payload, success or + failure, so it can be read unconditionally — that shape constancy is the point + of the change (maintainer ruling 2026-08-25, option 1 of three; option 2, + "carry them only where the text face printed them", was rejected as the hardest + contract to declare). + + Advisories stay CARRIED, never recomputed: each list is still computed at + exactly the step that owns it, so an exit upstream of a step legitimately + reports that list empty and no failure path pays for a computation it did not + already do. +- 3637731: `os lint` and `os i18n extract` gain the flow/screen bucket — a screen-flow + copy gap is reportable, and the `flows..screens..` skeleton is scaffoldable, + for the first time + + #11287 gave the bundle a `flows` group and a resolver that applies it. Nothing + on the CLI side walked it: `COVERAGE_SOURCE` had no flow bucket, so the + `i18n/missing-*` family could not report a screen-flow copy gap **at all**, and + `os i18n extract` never wrote the keys, so an author had no way to discover the + vocabulary. Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on + a tree whose six screen dialogs rendered English in all four locales. An app + whose i18n gate is green is green because the surface is invisible to it. + + The shared walker (`collectExpectedEntries` — one definition of what is + translatable, feeding both the gate and the extractor) now harvests, per flow: + + ``` + flows..label + flows..screens..title + flows..screens..fields..label + flows..screens..fields..placeholder + ``` + + Screens are keyed by `FlowNode.id` and fields by `ScreenFieldConfig.name` — the + identifiers the runner already holds at render time, not a second naming + scheme. Missing keys report as `i18n/missing-flow`, per locale, with the same + opt-in rule as every other bucket: a project that declares no locales and ships + no bundle still reports nothing. + + The copy keys are **imported** from `@objectstack/spec/system` + (`FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`), never restated. They + are exported precisely so the extractor and the resolver cannot drift; the + schema-to-list agreement is pinned spec-side in `translation.test.ts`, and the + list-to-walker agreement is pinned here. + + Two seeding rules follow what the reader actually sees rather than which key + the author filled in. A screen's `title` falls back to the node `label`, + because the executor builds the wire title as `config.title ?? node.label` and + one bundle key covers whichever the runner draws. A field's `label` falls back + to its `name` as a *derived* seed: the skeleton stays usable while the gate + demands no translation for a string nobody authored. +- 79cf692: feat(cli): `os validate --json` and `os build --json` carry the computed `conversions` on every failure exit, not the success payload alone (#12125) + + **Machine-contract widening on the `--json` failure payloads.** A consumer that + today branches on `conversions` being ABSENT from an `os validate --json` or + `os build --json` failure payload sees a different shape after this change. + + ## What was wrong + + `conversionNotices` is filled by the `onConversionNotice` sink handed to + `normalizeStackInput`, which runs at **step 2** — above the schema parse and + above every later gate in both commands. The notice was therefore already in + hand when any failure exit fired, and was then discarded: `conversions:` was + published on the terminal SUCCESS payload alone. + + An ADR-0087 D2 conversion notice is the one advisory class that carries an + **expiry** — `retiresIn` names the protocol major where the old shape stops + loading. So a CI job gating on `os validate --json` / `os build --json` could + not see that its tree depends on a conversion about to retire for as long as + the tree also tripped any unrelated gate — the notice was withheld exactly + while the tree was broken, which is when an author is most likely editing it. + + This is the same "computed, then dropped on a failure exit" shape as the + `warnings` family (#11643 / #11391 / #11772 / #12047), one field over. #12079 + added `warnings` to all nine `os build` failure exits and deliberately left + `conversions` untouched, so closing those cards did not close this one. + + ## What changed + + `conversions` is now present on every `emitJson` exit of both commands — + 6 in `validate.ts` (5 failure + success), 10 in `compile.ts` (9 failure + + success) — alongside each exit's existing keys, which are unchanged. + + | command | exit | `conversions` before | after | + | --- | --- | --- | --- | + | `os validate --json` | protocol parse failure | absent | the computed notices | + | `os validate --json` | author-time rules failed | absent | the computed notices | + | `os validate --json` | capability provider check | absent | the computed notices | + | `os validate --json` | package docs failed | absent | the computed notices | + | `os validate --json` | thrown / caught | absent | what the run had computed | + | `os build --json` | all nine failure exits | absent | what the run had computed | + + The success payloads are unchanged in content. + + Notices are **carried, not recomputed**: the fix is a pure scope change — the + sink array is declared above the `try` so the catch-all exit can read it — and + `normalizeStackInput` still runs at exactly step 2. A run that throws in + `loadConfig`, above step 2, therefore reports `[]` honestly. + + ⭐ Note the two fields differ on `os build`'s two earliest exits. For `warnings`, + `--strict-body` and the protocol parse are empty by construction (nothing + advisory is computed that early); step 2 is **above** both, so `conversions` is + populated there. The field was measured per exit rather than inherited from the + sibling change. + + ## What a consumer keying off its absence should do instead + + ⛔ `conversions` is no longer a signal of which exit produced the payload, nor + of success. Read `valid` (validate) / `success` (build), and `error` / + `errors`, for that; a consumer that inferred "this is a failure payload" from a + missing `conversions` must switch to the explicit status field. + + ⛔ `conversions: []` on a failure payload does NOT mean "this tree converts + nothing". It means **this run stopped before the conversion layer ran** — a + config that fails to load reports `[]` by construction. A consumer that needs + the true conversion set for a tree must read it from a run that reaches at + least step 2. + + ✅ `conversions` is always an array on every `os validate --json` and + `os build --json` payload, success or failure, so it can be read + unconditionally — that shape constancy is the point of the change (maintainer + ruling 2026-08-25 on #11772/#12047, option 1 of three, applied here under the + same-family rule; option 2, "carry it only where the text face printed it", was + rejected as the hardest contract to declare). + + ✅ Each entry keeps its structured fields — `conversionId`, `surface`, `from`, + `to`, `path`, `toMajor`, `retiresIn` — on failure exits exactly as on the + success payload, so a CI job can gate on `retiresIn` without a second run. + + Exit codes are untouched: every failure exit still exits 1. `--strict` on + `os validate` still reads the text face's own list, which folds conversion + notices in, so `os validate --json --strict` reaches the same verdict it did + before. + + `warnings` and `conversions` remain **separate fields**. Whether the two should + be folded into one is a live question raised on #12125 and not settled by the + ruling; this change deliberately mirrors the `warnings` shape rather than + merging either field into the other. +- ab6dd32: feat(cli): `os serve` says so when its port search runs out of ports, in the words the search threw (#12620) + + In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` walks + forward from the requested port looking for a free one. That walk gives up after + 101 ports, and when it does it throws a message naming the problem exactly — + which the caller then discarded, fell through, and bound the requested port + anyway: the one port the search had *just proven* was taken. + + That boot died on the kernel's raw `EADDRINUSE` with no explanation anywhere. It + is the one shape in the whole port policy that reached **neither** half of this + family's legibility work: the production `Port … is already in use` line lives in + a branch this boot never enters, and the shifted-port notice (#12543) is printed + only when the bound port differs from the requested one — on this path they are + the same, because the search threw before it could assign. The accurate sentence + existed and was thrown away one line earlier. + + The fallthrough is unchanged and still deliberate — a developer whose whole next + span is busy arguably does want the requested port attempted rather than a hard + refusal, and whether it should refuse instead is a separate policy question + (#11113). What changed is that it is no longer silent: + + ``` + ⚠ Could not find an available port starting from 32869 + Development auto-shift probed 101 ports (32869–32969) and every + one was busy, so this server is falling back to 32869 — the port the + search has just proven is taken. The bind that follows will almost + certainly fail with a raw EADDRINUSE from the kernel, and this notice + is the only place that says why. + Free a port in 32869–32969, or pick another via PORT= (or --port ). + ``` + + The first line is the search's own thrown message, carried rather than + paraphrased, so there is one spelling of that fact and not two that can drift + apart. The width it reports is read from the same constant the walk uses, so the + range it names is always the range it actually probed. + + Written to **stderr**, like every other `os serve` diagnostic: `stdout` carries + JSON-RPC frames whenever the stdio MCP transport is mounted, so nothing but + protocol may go there. It prints only when the search is exhausted — an ordinary + auto-shift and a production boot are both unchanged, byte for byte. +- 0043c92: feat(cli): `os serve` announces a shifted port, naming the one you asked for and the one it took (#12543) + + In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` hops to + the next free port when the requested one is taken, so several example apps can + run side by side. That behaviour is unchanged and deliberate — production still + refuses to drift (#11113). What changed is that the hop is no longer silent. + + Previously the only trace of a shift was the ready banner printing the port that + was *bound*; nothing said it was not the port that was *asked for*, so every + reader had to already know the requested port and compare the two by hand. A + boot that shifts now prints, before anything else: + + ``` + ⚠ Port 32869 is in use — serving on 32871 instead. + Development auto-shift: 32869 was not free, so this server took + the next one that was (32871). Anything still pointed at 32869 — a + proxy, an OAuth callback URL, another terminal, a test harness — is + talking to whatever holds 32869, not to this server. + ``` + + The notice is written to **stderr**, like every other `os serve` diagnostic: + `stdout` carries JSON-RPC frames whenever the stdio MCP transport is mounted, so + nothing but protocol may go there. It prints only when the bound port actually + differs from the requested one — an ordinary boot on a free port is unchanged, + byte for byte. +- c3b771c: fix(cli): `os serve` refuses a port that cannot be a port, naming what the operator set (#12662) + + `--port` was a string flag whose only consumer was a bare `parseInt`. `os serve + --port abc` therefore became `NaN`, travelled the whole port policy untouched, + and reached the real `listen()` — which refused it at the socket layer: + + ``` + ERR_SOCKET_BAD_PORT: options.port should be >= 0 and < 65536. Received type number (NaN). + ``` + + An operator who mistyped a flag got back an error naming an internal option, + raised from a code path with no connection to the thing they typed. `--port + 99999` parses fine and died in exactly the same place, and `PORT=abc` / + `OS_PORT=abc` are the same defect through a different door. + + The value is now checked before anything is done with it: + + ``` + ✗ Invalid port: OS_PORT="abc" + A port must be a whole number from 0 to 65535 — 0 is legal, and + asks the kernel for any free port. Nothing was started, and no socket + was opened. + Correct OS_PORT in this process's environment (for example OS_PORT=3000), + or override it with --port 3000. + ``` + + It names **which** input was used — `--port`, `PORT` or `OS_PORT` — because a + refusal that only said "invalid port" would repeat the defect one level up. The + range it states is interpolated from the bounds the code enforces, so the + sentence cannot drift from the check. `0` is accepted: `listen(0)` binds a + kernel-assigned port, so refusing it would have broken a working input in the + name of validating it, and the ceiling is 65535 — one less than the `< 65536` + the kernel's own message names. + + The check sits ahead of the port-conflict policy, so all three boot paths (the + development auto-shift, the production refusal, and a boot that enters neither) + are covered by one guard, and all three inputs are covered with it: `PORT` and + `OS_PORT` never reach flag parsing at all — they are read by the flag's + `default`, which oclif never runs a flag's parser over. + + **No value that boots today is refused.** `parseInt` remains the reader, so + every spelling it tolerates — `" 3000"` with the leading whitespace production + environments carry, `"3000.0"`, `"0x0BB8"`, `"+3000"` — still boots, on the same + port, byte for byte. Only the values that used to die at the socket are refused, + and now they are refused early, in the operator's own vocabulary. Written to + **stderr** like every other `os serve` diagnostic: `stdout` carries JSON-RPC + frames whenever the stdio MCP transport is mounted. +- 2805e52: feat(cli): `os serve` says so when a port is read as something other than what the text says (#12674) + + `os serve` reads its port with `parseInt`, and `parseInt` is tolerant in a way + that changes the *answer* rather than the spelling. `--port 3e3` binds port + **3**. `--port 0x0BB8` binds 3000. `--port 3000abc` binds 3000. The boot + succeeds, on a port the operator never named, and nothing anywhere says so — an + operator who wrote `PORT=3e3` meaning 3000 gets a server on port 3, and on a + non-root host that surfaces (much later, if at all) as an `EACCES` that still + does not name the coercion. + + The value is now announced when it does not read as the port it selected: + + ``` + ⚠ PORT="3e3" was read as port 3. + That text is not a plain decimal number, and the reader that accepts it + is tolerant: it honours a leading 0x as hexadecimal and discards + everything from the first character that cannot continue the number. + Nothing downstream reads it again — 3 is the port this server asked + for, whatever the text looks like. + If that is not the port you meant, correct PORT in this process's + environment (for example PORT=3000), or override it with --port 3000. + ``` + + **Nothing is refused, and nothing binds differently.** The accept set is exactly + what it was: every spelling that boots today still boots, on the same port, byte + for byte. Whether `os serve` should take only strict decimal text is a contract + question about a published CLI's accepted input, and it is deliberately left + open. This repairs the silence, which is where the harm actually was. + + The notice fires on a *difference*, so what counts as agreement is the whole of + it: leading and trailing whitespace, a leading `+` and leading zeros do not + change what the text says (`" 3000"`, `"+3000"` and `"08080"` are silent — the + first is what production `PORT` values look like, and a notice there would drone + at every ordinary boot). An exponent, a radix prefix, a fraction, a digit + separator or trailing text all do (`"3e3"`, `"1e10"`, `"0x0BB8"`, `"0b111"`, + `"3000.0"`, `"1_000"`, `"3000abc"` all speak). + + It names both the text and the port it selected — never a third number, because + `3e3` looks like 3000 to a reader but `3000abc` has no second reading and a + guess would be wrong the first time it met one. Written to **stderr** like every + other `os serve` diagnostic: `stdout` carries JSON-RPC frames whenever the stdio + MCP transport is mounted. +- 8542bd4: **Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + + A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + + What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + + ```js + // this record — nameField, formula or stored column alike + await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); + // a related record, through the lookup column that holds its id + const account = await ctx.title('account_id'); + ``` + + **Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + + **Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + + **It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + + Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. +- 09b4f4e: feat(platform-objects,cli): record which source revision a generated translation leaf was filled from (#11671) + + Closes the half of the sticky-translation-drift class that no value comparison + could reach, under maintainer ruling #12069 Option A — by extending the existing + #8765 Option B source-hash mechanism to the generated bundles rather than + building a second one. + + **The hole.** `os i18n extract --fill=default` fills gaps only: any non-empty + value in a translated locale wins forever. So the ordinary sequence — extract, + revise the source string, extract again — rewrites `en` and strands the previous + source text in every other locale. The bundle is still in sync by key, so + `check:i18n` reports OK; the leaf is still present, so `check:i18n-coverage` + counts it translated. Measured on #11659 at `bbe0b17`: three locales serving a + 602-char superseded draft of a 411-char help string under 31 green checks. Once + the source has moved, that stale fill is indistinguishable **by value** from a + real translation — 2648 of 3010 leaves differ from `en`, so "untranslated AND + differing from the source" describes an empty set, not a noisy one. + + **What is new.** `os i18n extract --source-hashes` writes + `.source-hashes.generated.ts` beside each generated bundle: per leaf, + the digest of the source revision that leaf is **still a byte copy of**. + `withSourceFallback` takes that table as a fourth argument and now judges the + `objects` / `metadataForms` sections as well as the hand-authored ones, so a + leaf whose source has moved underneath it serves the current source string + instead of a superseded draft — the same degradation an untranslated key already + produces, which is the invariance the #8765 ruling turned on. + + The generated half needs one conjunct the hand-authored half does not: the leaf + must still hold the recorded bytes. Its hash table is itself generated, so a + translator cannot be asked to refresh a digest by hand the way + `.source-hashes.ts` asks; without that conjunct, re-translating a stale + leaf would leave the old record standing and report the fresh translation as + stale forever. With it, editing the value clears the flag by itself. + + **Behaviour on the day it lands: unchanged for every leaf.** Records are + written only where a leaf is currently a byte copy of the **current** source, so + every record equals the current digest and nothing is stale. Measured across the + nine bundle sets: 9030 translated leaves, 1543 byte-equal to `en` (records + written), 7487 differing (left with no record — legacy-trusted, per the ruling's + property 1, since nothing in the tree says which revision they were made from). + No committed bundle changed a byte. + + **Scope.** `--source-hashes` is off by default and `@objectstack/platform-objects` + is the one bundle set that opts in, by documenting the flag in its extract + config. The other eight sets keep exactly today's behaviour and can be enabled + file-by-file later; a set with no companion is entirely legacy-trusted. + + The false "this hole cannot occur there" note that kept the generated sections + out of the mechanism is corrected in `source-hash.ts`, with the measurement that + falsifies it. +- 37e82eb: Surface a licensed `max_nodes` oversell to operators as telemetry + + `os serve` already warned loudly at boot when `OS_CLUSTER_REPLICAS` declared more + nodes than the licence gate admits, but that warning existed only in one + process's startup output: an operator who scaled past their cap three weeks ago + had no way to ask the question today, and no way to alert on it. The same + advisory verdict is now also published through the deployment's configured + metrics backend, so it reaches the place operators already look. + + Three names join `SEMCONV` in `@objectstack/observability`, emitted once per boot + by `os serve` when a remote cluster driver is configured, each labelled with the + gate's own verdict vocabulary (`admitted` / `capped` / `refused`): + + - `cluster_declared_nodes` (gauge) — the replica count the operator **declared**; + - `cluster_admitted_nodes` (gauge) — how many of them the licence **admits**; + - `cluster_node_cap_verdicts_total` (counter) — one increment per process boot + that consulted the gate, so an alert stays writable after a one-shot gauge has + aged out of a push-based backend. + + **Visibility only — the cap remains advisory and nothing is refused.** The gate is + consulted once per process at boot, every replica computes the same verdict, and + none can know whether it is one of the admitted ones, so all of them still join. + The names say so on purpose: this process has no cluster membership view at all, + so a series called `cluster_nodes` or `cluster_active_nodes` would be a false + statement dressed as telemetry. Nothing here counts peers, and no accept/reject + behaviour changed. + + Absence is meaningful rather than an instrumentation gap: a single-node + deployment never consults the gate and emits nothing, and an emission also needs + a metrics backend configured via `OS_OBS_EXPORTER`. +- 0e5bea6: New operator-run command `os migrate multi-value-columns`: migrates a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — the `manual_column_type_change` drift `os migrate apply` reports and deliberately never reconciles for you (#11535, ruled C on #11700). Flags: `--apply` (default off), `--yes`/`-y`, `--force`, `--table ` (repeatable), `--database-url`, `--json`. **Dry-run contract: without `--apply` the command executes nothing at all** — it prints the exact statements and the database they would run against, opens no seam and issues no probe, and a run is verified to have left the column type and every row unchanged. `--apply` runs `@objectstack/driver-sql`'s own `manualJsonConversionSql` — newly re-exported from that package's index for this consumer, its only other change — i.e. the statement the drift finding itself prints (Postgres: one `ALTER … USING (CASE …)` with `json_build_array`; MySQL: the two row-shaping `UPDATE`s then `ALTER … MODIFY … json`), refuses to execute anything the finding does not contain verbatim, re-runs detection afterwards and exits non-zero if the finding has not cleared. SQLite is excluded — the stale column round-trips a real array there, so the finding is never raised. Rows corrupted before the column is migrated are out of scope, and the command is never invoked automatically: nothing on the boot path reaches it. +- f2dc3ac: fix(cli): `os migrate plan` / `apply` exit non-zero when the host config exists but could not be loaded (#12953) + + A host `objectstack.config.{ts,js,mjs}` that EXISTS and throws while loading — a + missing environment variable is the ordinary cause, and ObjectStack Cloud's own + control-plane config throws without `AUTH_SECRET` — used to warn loudly and then + **exit 0**. The object set the commands diffed on that path is the data stack + plus the platform floor: nine tables, none of them the deployment's, and `0` + drift over them printed "Physical schema is in sync with metadata — nothing to + migrate". Measured on the fixture this ships with, before the change: `plan`, + `plan --json`, `apply --yes` and `apply --yes --json` all returned `0`. + + Maintainer ruling 2026-08-29, verbatim 「同意」: a green exit over an UNMEASURED + partial metadata set is the false-green a migration tool must never emit, and + the population this "regresses" was computing defective plans all along. Both + commands now exit **non-zero** on that path, with an error on stderr naming the + config file, the underlying failure, and the remedy. + + **BEHAVIOUR CHANGE to exit status**, shipped as `minor` under the repo's + launch-window convention. It is scoped to exactly one shape, and the two + neighbouring ones were measured byte-identical before and after — stdout *and* + stderr, human and `--json`, for both commands: + + - host config **present and unloadable** → non-zero (this change); + - host config **absent** → unchanged, still exit 0. `hostConfigLoaded` is + `false` on that shape too, so the refusal keys on `hostConfigPath !== null` + rather than on the flag alone; + - host config **present and loadable** → unchanged, still exit 0. + + Everything the previous behaviour emitted is kept, deliberately: the loud stderr + warning, and the `composition.hostConfigLoaded` discriminator in the `--json` + payload that consumer coverage gates (objectstack-ai/cloud#1705) read — a table + count cannot replace it, because the platform floor raises the count either way. + The refusal changes the exit STATUS, not the document: the whole plan, or the + whole JSON payload, is still written before the process exits non-zero, and the + unloadable path's payload is byte-identical to the one it emitted before. + + **Migration.** A CI step that runs `os migrate plan`/`apply` against a project + whose config needs environment it was not given now fails instead of reporting + success over a fraction of the deployment. Supply that environment to the run + (the error names the missing variable), or fix the config. +- 6a571d3: feat(cli,plugin-hono-server): declare `exports` maps, and make "a published package declares one" a gate (#12879) + + **BREAKING** removal of reachable subpaths, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + **FROM.** Both packages declared `main` + `files` and no `exports`. Under Node's + resolution that leaves every module under `dist/` importable from outside the + package, whatever the entry barrel names — `@objectstack/cli/dist/utils/lower-callables.js` + resolved, and so did every other `dist/**` path in either package. They were the + only two of the 69 publishable packages here in that shape. + + **TO.** Each declares exactly the entry it means to offer, and nothing else: + + ```jsonc + // @objectstack/cli — ESM-only (tsc, "type": "module") + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } + + // @objectstack/plugin-hono-server — dual build (tsup esm+cjs) + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } } + ``` + + The root entry resolves to exactly what `main` / `types` already pointed at, so + `import … from '@objectstack/cli'` and `require('@objectstack/plugin-hono-server')` + are byte-for-byte unchanged. `@objectstack/cli` uses `default` rather than `import` + on purpose: it is ESM-only, and an `import`-condition-only map would ALSO refuse + CJS `require()`, which is a second break this change is not making. + + The CLI's `bin` is untouched. `exports` gates specifier resolution only, and the + executable is reached by path — the `objectstack` / `os` shims, or + `node node_modules/@objectstack/cli/bin/run.js` as the Dockerfiles and the + showcase Playwright config spell it. + + **Migration.** A consumer that deep-imports `@objectstack/cli/dist/**` or + `@objectstack/plugin-hono-server/dist/**` now gets `ERR_PACKAGE_PATH_NOT_EXPORTED`. + Import the symbol from the package root instead. If it is not exported there, it + was never an offered surface — the deep path resolved by omission, not by + decision, and the fix is an issue naming the use case rather than a map entry + that would ratify it (ratifying accidental reachability prices every later + internal refactor of these packages at a minor bump — the trap this change + exists to close, in the other direction). + + Measured before the maps were written: **one** in-repo deep importer, + `packages/qa/dogfood/test/build-shaped-artifact.ts`, which now reads the CLI's + `lowerCallables` as SOURCE by relative path — the shape the rest of that suite + already uses for package-internal reads, and one that keeps the util internal + (#6293: reach the goal without growing `@objectstack/cli`'s public entry). + `@objectstack/plugin-hono-server` had **zero**. ⚠️ Deep importers OUTSIDE this + repo cannot be measured from inside it; they are the residual risk of this + release, and they break at the import rather than silently. + + **The class, not the two instances.** `pnpm check:published-files` gains a sixth + invariant — GATED: a publishable package declares an `exports` map — turning a + convention that held for 69 of 71 into a written ratchet. It carries the census + control the ruling requires: the gate reads a POSITIVE signal off every manifest, + so a broken reading (no members enumerated, a key read under the wrong name, a + parse that drops manifests) would make "nobody violates GATED" true and green. + A census that finds nobody declaring `exports` therefore fails as an INSTRUMENT + error, in its own words, and the self-test holds the floor inside a band against + the live tree in both directions. + + +- 366f895: feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) + + The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, + `/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, + replaced by seven new models and a three-way connection contract. This lands + the migration atomically: + + - **Seven new platform objects** back the stable models — + `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, + `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, + `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, + registered in the platform-object-names registry, listed in + `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose + `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group + provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — + is closed). + - **SCIM connections stay runtime data.** The stable constructor is satisfied + with an application-owned `authentication.verifyBearerToken` that resolves + the connection from a row at request time — not static boot config, and not + the upstream `managedConnections` catalog (deliberately not adopted). + - **ObjectStack owns SCIM credentials outright** (stable upstream stores no + credential at all): `sys_scim_connection_credential` plus + `scim-connection-service.ts` mint/digest/verify. At rest only an + HMAC-SHA-256 keyed by the deployment auth secret (base64url, + domain-separated) is stored — at parity or better than the rc.1 unsalted + SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live + 401 paths for forged, revoked and expired bearers. + - **The ObjectQL better-auth adapter gains native transactions** + (`engine.transaction`, fail-closed on drivers without `beginTransaction`), + which stable scim requires by assertion for atomic provisioning writes. + - **Scaffold suppression retired**: the `@better-auth/scim>better-call` + `allowedVersions` entry (CLI renderer + blank template) is gone — stable + 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped + to absence pins. The `better-auth>better-sqlite3` and four + `@better-auth/utils` entries stay; their retirement conditions are separate + and unmet. + - The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers + `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace + overrides' silencing would "satisfy" while the family is 1.7.1. Floating is + its own follow-up. + + **Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token + endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — + but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a + documented "do not let the IdP push groups" boundary, and the maintainer ruled + (2026-08-25) that SCIM has no real customers and old data need not carry: the + one binding constraint is that an existing system upgrades smoothly, which it + does — every table the installed library can write exists at this version, and + SCIM-disabled deployments see no behavior change. A major would move the whole + fixed version group for a feature surface with zero consumers. Deployments + that had SCIM enabled must mint new connection credentials (digests are not + portable from rc.1 on any path — IdP token reissue is a migration-day + operator action regardless of semver level). `sys_scim_provider` itself is + NOT removed here; its retirement is tracked separately (#11757). +- 96c796c: New operator-run command `os secret orphans`: reports the `sys_secret` rows no producer references any more, and — only behind `--delete` — removes the ones the maintainer ruling permits. **Report-only by default: without `--delete` it writes nothing and deletes nothing.** Flags: `--delete`, `--export `, `--declared-datasources `, `--no-declared-datasources`, `--yes`/`-y`, `--database-url`, `--json`. + + It closes the residue #8030 / PR #8063 deliberately left open: that fix reaps forward-only, on the write that retires a handle, so rows orphaned by rotations that already happened on a deployed instance are untouched and nothing else ever touches them. ⛔ Not a boot-time migration and not a `lifecycle`/retention policy — nothing on any boot path reaches this command; age is not unreferencedness, and an age sweep takes the in-force oldest rows first. + + **The deletion predicate is "attributable AND unreferenced by the COMPLETE cross-producer reference union", and incompleteness refuses.** `sys_secret` has three producers and no producer column, so "unreferenced by `sys_setting`" is not "unreferenced": a live, engine-owned credential lands in the settings-scoped classifier's `orphaned` bucket, which is reproduced against the real producers in the new test rather than argued. The command therefore decides on the union of all three holder families and **refuses to delete whenever any family could not be enumerated, naming that family** — the union's three independent gap sources (the host did not declare its code-defined datasources; the engine exposes no `listDatasourceDefs()`; neither reaches) are carried through per family to the operator, to `--json` and into the export, never flattened to a boolean. Saying nothing about declared datasources is a gap, not `[]`: `--no-declared-datasources` is how a host states it has none. + + Three further refusals are mechanical rather than advisory: `unattributable` rows are never deletable; a row whose `(namespace, key)` currently resolves through a **legacy inline** `sys_setting` value is withheld (the #8063 prefix guard in the opposite direction — an inline value names no handle, so it explains the absence); and re-wrap evidence (`version` / `rotated_at`) is reported but is **never** a verdict input, because `rotateKey()` keeps the handle stable and a re-wrap is not a retirement. + + **The pre-delete export is mandatory** and carries the cipher material of every row about to be deleted: the settings audit trail records content digests rather than handles, so without it an erroneous delete could be neither named nor undone. It is CREATED owner-only or not written at all — the write is an exclusive create, so a path that filled up between the early `--export` refusal and the write itself fails rather than truncating a file whose owner and permissions it would then have inherited — and it is read back and checked against the plan before a single row is removed. + + ⚠️ The operator-facing text states the measured framing, which is inverted: on the pre-fix rotation path the handle was never repointed, so the value **still in force** is the oldest one — the credential the administrator believed they had replaced — while each orphan holds a value that never took effect. Deleting orphans therefore retires nothing that is exposed, and if the administrator also rotated at the provider the newest orphan may be a credential that is currently valid there. +- 918988a: fix(cli): `os serve` defaults `NODE_ENV` to `production` when unset, exactly as `os start` already does (#11113) + + **BREAKING for a deployment that runs `os serve` with `NODE_ENV` unset and + relies on a development-class convenience surviving into a real boot.** + Shipped as `minor` under the repo's launch-window convention for breaking + changes, not `patch` — this is a deliberate default flip, not a bugfix that + restores previously-intended behaviour. + + `os start` has forced `NODE_ENV='production'` on the unset case since #5673, + but it does so on the child environment it assembles for its **spawn** + (`start.ts:347`). `os serve` runs **in-process** — there was no equivalent + write, so the whole family of `NODE_ENV !== 'production'` gates across the + tree read the raw `undefined` and took the non-production branch on a boot + that never declared itself anything else. Filed as #11113, the declared + residual of #10366 (which closed the same gate's *set-but-wrong* case and + left this one for its own card, per the disposition precedent on #11035). + + One line: `serve.ts` now defaults `process.env.NODE_ENV` to `'production'` + when unset, at the same point it already defaults it to `'development'` under + `--dev` — before any of the runtime modules it dynamically imports, and before + every gate downstream reads the variable. An explicitly-set `NODE_ENV` + (`development`, `test`, anything else) is never overridden. + + The full behaviour-flip survey — every `NODE_ENV`-reading predicate in the + tree, which ones flip and which don't, and why — is in the PR body (#11113), + not repeated here. Highlights of what an unset-`NODE_ENV` `os serve` boot now + gets, that it did not before: + + - plugin-auth's localhost trusted-origin CSRF substitution closes (the + regression this card pins). + - plugin-auth's CSRF Origin/Referer synthesis for headerless requests closes. + - plugin-auth's missing-`OS_AUTH_SECRET` fallback to a forgeable + `dev-secret-` becomes a refusal to boot instead. + - plugin-auth stops printing invitation / magic-link URLs and OTP codes to + logs. + - plugin-dev's ADR-0115 D6 boot guard now refuses to initialize the dev + assembly (well-known auth secret, seeded dev admin) instead of loading it. + - the SQL driver's auto-DDL guard stops silently applying `safe` schema drift. + - the seed loader stops seeding dev-scoped datasets into what it previously + could not tell apart from production. + - service-settings' local crypto provider now requires a stable key instead + of tolerating an auto-generated / ephemeral one. + + Every one of those is the intended tightening this card exists to make: an + operator (or an AI-authored deploy script) that never exported `NODE_ENV` is + running a real deployment, and the safe direction is to treat it as one, loud + failures included, rather than silently keep a development-class door open. + `NODE_ENV=development` / `NODE_ENV=test` — including the flows `os dev` and + `os serve --dev` already carry — are unaffected; only the unset case moves. + + +- 33e81a5: feat(cli): `os validate --json` carries the computed advisory lists on every failure exit, not the success payload alone (#12047) + + **Machine-contract widening on the `--json` failure payloads.** A consumer that + today branches on `warnings` being ABSENT from an `os validate --json` failure + payload, or that reads it as "the author-time rule advisories", sees a + different shape after this change. + + ## What was wrong + + The text face prints its advisory blocks ending `— re-run with --json for the + full list`, but `warnings` lived on the TERMINAL SUCCESS payload only — plus + `ruleAdvisories` alone on two of the five failure exits. So the remedy the + notice named returned a payload that did not contain the list, and the author + could not reach the withheld entries by any route until an unrelated later + failure was fixed. + + The strongest instance is the parse-failure exit. `validate.ts` computes the + #3786 undeclared-authoring-key findings **before** the schema parse, precisely + so a finding survives an unrelated schema error — the parse is what strips the + key, so it cannot be recovered afterwards. That payload then dropped the list + anyway, defeating the one hoist that existed to prevent exactly this. + + ## Which exits gain the field + + All five failure exits of `os validate --json`. Two already carried a partial + list; three carried none. `warnings` is now present on every one, alongside + each exit's existing keys, which are unchanged: + + | exit | existing keys | `warnings` before | after | + | --- | --- | --- | --- | + | protocol parse failure | `errors` | absent | undeclared-key findings | + | author-time rules failed | `errors` | `ruleAdvisories` | rule + key | + | capability provider check | `errors` | absent | rule + key + capability | + | package docs failed | `errors` | `ruleAdvisories` | rule + doc + key + capability | + | thrown / caught | `error` | absent | what the run had computed | + + The success payload is unchanged in content: its + `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings]` + spread moved to a single `warningsSoFar()` site that every exit now reads, so + the member order cannot drift between exits. + + `structuralWarnings` is the one member `os validate` has that `os build` does + not, and it is **carried, not hoisted**: it is computed below all five failure + exits, so it rides each of them as an empty list and the success payload stays + the only exit that can ever show it non-empty. + + ## What a consumer keying off its absence should do instead + + ⛔ `warnings` is no longer a signal of which exit produced the payload. Read + `valid` (and `error` / `errors`) for that; a consumer that inferred "this is a + failure payload" from a missing `warnings` must switch to `valid === false`. + + ⛔ `warnings` on a failure payload is no longer only the author-time rule + advisories. It is the same heterogeneous list the success payload publishes — + rule and doc findings as RECORDS, undeclared-key and structural advisories as + STRINGS — truncated to what the run had computed. A consumer that assumed every + entry was a rule finding must classify by shape. + + ⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no + advisories". It means **this run stopped before those advisories were + computed** — a config that fails to load reports `[]` by construction. A + consumer that needs the full advisory set for a tree must read it from a run + that reaches at least the gate that computes it. + + ✅ `warnings` is always an array on every `os validate --json` payload, success + or failure, so it can be read unconditionally — that shape constancy is the + point of the change (maintainer ruling 2026-08-25 on #11772, option 1 of three, + inherited here under the same-family rule; option 2, "carry them only where the + text face printed them", was rejected as the hardest contract to declare). + + Exit codes are untouched: every failure exit still exits 1, and `--strict` + still reads the text face's own list, so `os validate --json --strict` reaches + the same verdict it did before. + + Advisories stay CARRIED, never recomputed: each list is still computed at + exactly the step that owns it, so an exit upstream of a step legitimately + reports that list empty and no failure path pays for a computation it did not + already do. +- ab23c67: fix(cli): `os validate --json --strict` exits 1 on the configs `--strict` already exits 1 for (#11174) + + `commands/validate.ts` emitted the `--json` payload and `return`ed *above* the only + `flags.strict` reader, which sat inside the text-rendering block. So on one config, + one flag, two answers: + + ``` + os validate --strict → exit 1 ("Strict mode: warnings treated as errors") + os validate --json --strict → exit 0 + ``` + + `--strict` was accepted, documented — `content/docs/deployment/cli.mdx` spells + `os validate --json --strict` twice in its CI/CD section, once as a GitHub Actions + step — and inert whenever `--json` was also passed. That combination is the one + audience the flag exists for: a pipeline gating on the exit status of the documented + invocation read 0 and concluded the stack was clean. + + The `--strict` gate now reads the text face's own warning list, which is assembled + once and consumed by both faces, so the two exit codes cannot drift apart again. The + gate deliberately does **not** read the payload's `warnings` field: the two differ by + the ADR-0087 load-time conversion notices, which the text face folds into its warning + block while the payload carries them under `conversions`. Gating on the field would + have left the same divergence in place for a config whose only advisories are + conversion notices. `specVersionGap` stays outside `--strict` on both faces, as it + always has been on the text one. + + `valid: true` beside a non-zero exit is the text face verbatim, not a contradiction: + that path prints "Validation passed" and *then* fails for strict. The stack is + schema-valid; `--strict` is what promotes its advisories to a failure. + + **BREAKING** for one caller shape, and the reason this is not a patch: a pipeline + running `os validate --json --strict` over a stack that raises non-blocking + advisories was green and will now be red. Nothing was removed or renamed and no + authored metadata changes — the accept set is identical and the exit status is the + only thing that moves — but a release a CI system can take unattended must not flip + a green build to red, so this does not belong in a patch. It is not a major either: + the new behaviour *restores* what `--strict` declares ("Treat warnings as errors") + and what the docs already advertise, rather than contradicting a contract. Under + this repo's launch-window convention (breaking changes ship as `minor` while the + stack versions in lockstep) `minor` is the honest slot. + + ## What a pipeline gating on the payload has to read + + `--strict` gates on the text face's warning list, and that list is **not** the payload's + `warnings` field. The two differ by the ADR-0087 load-time conversion notices: the text + face folds them into its warning block, while the payload carries them separately under + `conversions`. So a pipeline that wants to reproduce `--strict` from the document must + read **both**: + + ``` + warnings.length > 0 || conversions.length > 0 + ``` + + Gating on `warnings` alone is strictly weaker than `--strict` — a config whose only + advisories are conversion notices passes that check and fails `--strict`. That is the + same silent under-reporting this change exists to remove, so do not reach for the + narrower spelling. + + The consequence is reachable and worth stating outright, because it is surprising: a + conversions-only config now exits **1** with `"warnings": []` and a populated + `conversions`. Predicting the exit code from `warnings.length` alone will be wrong for + exactly that config. Nothing is missing from the document — both advisory streams are in + it — but they sit in two fields and the exit code answers to both. + + If a pipeline genuinely wants the old exit status, the honest fix is to say so rather + than to keep passing a flag that means the opposite: drop `--strict` and read the + payload. If it goes red instead, the advisories were always there — the text face had + been printing them all along. + + + +### Patch Changes + +- f648cbe: Carry the capability-provider (#3366) and package-docs (ADR-0046) warnings in + the `os build --json` payload, so its `warnings` list matches + `os validate --json` on the same tree + + `os build --json` reported a strictly smaller `warnings` list than + `os validate --json` did for the same stack. #11643 closed the gap for the + undeclared-authoring-key findings; two lists were still behind it — the #3366 + installable-provider hints (an unknown capability token, or a provider that is + absent but addable with `pnpm add`) and the ADR-0046 package-docs advisories. + A CI job gating on `os build --json` therefore read an empty advisory list for + a stack that names a typo'd capability and ships a doc whose frontmatter tags + were silently dropped, while the identical job gating on `os validate --json` + read both. + + Measured over one temp project at `origin/main` `589758d22`, both commands + exiting 0: + + ``` + os build ⚠ requires: "zzz_unknown_capability_token" is not a known platform capability — check for a typo. + ⚠ src/docs/advparity_guide.md: Frontmatter `tags:` … is not a list this reader understands … + os validate --json warnings: [ {doc record}, {token,message}, "No apps or plugins defined …" ] + os build --json warnings: [] ← both lists dropped + ``` + + `compile.ts` computed both and then rendered them **inside** the + `if (… && !flags.json)` print blocks, which put them structurally out of reach + of the payload: computed, then discarded, for the one audience `--json` exists + to serve. This is the fourth measured instance of that shape in these two files + (#10953, #11174, #11643), and it takes the established fix — hoist the + formatting to the computation site so one list feeds both faces and they cannot + report different sets. + + **Order and shape are mirrored from `os validate --json`, not chosen here.** + That payload reads `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, + ...capProviderWarnings, ...structuralWarnings]`; `os build --json` now emits + that list minus its last member. Doc advisories ride as the issue records + `collectAndLintDocs` returns and capability hints as `{ token, message }`, + which is what validate ships for each, so a consumer reads one shape per class + from either command rather than learning two. + + **No new key.** Both lists land in the `warnings` key the payload already + declared — "the whole registry's advisory set, in the shape `os validate --json` + reports", as its own comment has always said. The payload's top-level key set is + unchanged and pinned as unchanged. + + **`structuralWarnings` is deliberately not included.** `os validate` derives + four structural advisories ("No objects defined", "No apps or plugins + defined", and two manifest ones) from `collectMetadataStats`; `os compile` + calls that same helper but computes none of them, in any face. That makes it a + missing computation rather than a dropped list, and whether a command that + writes an artifact should raise them is a judgment rather than a mechanical + port. It is split out as #11896 and pinned as the only remaining residue between + the two payloads, so the question stays visible and a fifth genuinely dropped + list cannot hide in the gap. + + Text output is unchanged. +- 945ffbe: Carry the undeclared-authoring-key warnings in the `os build --json` payload, + so its `warnings` list matches `os validate --json` on the same tree + + `os build --json` reported a strictly smaller `warnings` list than + `os validate --json` did for the same stack, and the missing members were + exactly the "your key was dropped at load" ones (#3786 / ADR-0087). A CI job + gating on `os build --json` therefore could not see the class of warning that + silently discards authored metadata — while the identical job gating on + `os validate --json` did. + + Measured over one temp project at `origin/main` `4ceae8ab0`, three faces of one + authored stack whose field carries an undeclared key nested in `visibleWhen`: + + ``` + os build ⚠ Undeclared authoring keys (1) — dropped at load (#3786) + os validate --json warnings: [ {rule record}, "…zzzUnknownKey…" ] + os build --json warnings: [ {rule record} ] ← the dropped list + ``` + + `compile.ts` computed the findings and then formatted them **inside** the + `if (… && !flags.json)` print block, which put them structurally out of reach + of the payload: computed, then discarded, for the one audience `--json` exists + to serve. `os validate --json` had this exact defect on its own face and fixed + it by mapping the findings through `formatUnknownAuthoringKey` at the + computation site; `os build` now does the same, so one list feeds both faces + and they cannot report different sets. + + **No new key.** The findings land in the `warnings` key the payload already + declared — its own comment has always said "the whole registry's advisory set, + in the shape `os validate --json` reports" — carried as formatted strings + beside the authoring-rule records, which is byte-for-byte the heterogeneous + shape `os validate --json` already ships. Consumers reading `warnings` off + either command now read one shape for one class of warning. The payload's + top-level key set is unchanged and pinned as unchanged. + + This also makes an existing promise true. The truncation notice added in + 17.2.0 ends with "re-run with `--json` for the full list"; that pointer was + honest about the authoring-rule advisories and would have been false about the + undeclared-key list, which is why that change left the second list without a + notice. + + Text output is unchanged. +- a422843: `os build` says how many author-time warnings it withheld, instead of stopping + dead at 50 + + The author-time advisory printer emitted a fixed 50 detailed entries and then + stopped, with nothing in the output saying the list had been cut. Measured on + `objectstack-ai/hotcrm` with the published 17.1.0 CLI: two `objectstack build` + runs over the same tree, before and after a five-warning fix, printed 50 + detailed entries each — 184 output lines and 52 warning lines both times — + while the summary line counted 80 and then 75. The two numbers disagreed and + nothing explained why. + + The defect is the **silence**, not the cap. Truncated output that carries no + notice is not merely incomplete, it is indistinguishable from complete: an + author who reads the report and sees their file is clean has read a list that + stopped early. Because advisories are ordered by surface (pages, then views, + then flows), a repo whose page warnings alone exceed the cap keeps every `view` + and `flow` advisory permanently invisible — and fixing warnings then makes new + ones *appear*, which reads as a regression caused by the fix. + + The cap stays, and over it the output now names the exact remainder: + + ``` + ⚠ … and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list + ``` + + At or under the cap no such line appears, and the detail entries themselves are + byte-for-byte what they were. The pointer is `--json`, which already publishes + the whole set under `warnings` — an existing complete-output path rather than a + new flag. No new verbosity tier, no paging, no configuration surface. + + `os validate` was checked at the same time and does **not** truncate its + advisory list: it prints every warning it collected. Only the `build`/`compile` + printer had the cap. +- e278a29: fix(cli): `register`/`whoami`/`logout` examples no longer spell `os auth ` in live `--help` output (#11221) + + `static override examples` is printed verbatim as part of oclif's `--help`. `register.ts`, + `whoami.ts` and `logout.ts` live at the **root** of `packages/cli/src/commands/`, so oclif's + pattern-strategy loader registers them as `register` / `whoami` / `logout` — but their + `examples` spelled an `os auth ` shape that has never resolved. A user copy-pasting + straight out of `--help` hit `Error: Command auth:whoami not found.` (exit 2), the same dead + command #10927 fixed in `packages/cli/README.md` and #10967 fixed for the `environments` + topic, this time on the root auth-family commands. + + Measured against the built CLI (`packages/cli/bin/run.js`) before the fix: `os auth whoami`, + `os auth register` and `os auth logout` each exited 2 with `Error: Command auth: not + found.`, while the bare `os whoami` / `os register` / `os logout` each exited 0 and printed + help — so the examples named the one spelling that could not work. All seven `examples` + entries now say the bare, registered spelling. + + The exported default class on each file is renamed to match its real, file-path-derived + command id (`AuthRegister` → `Register`, `AuthWhoami` → `Whoami`, `AuthLogout` → `Logout`). + oclif derives a command's id purely from its file path, never from the class name, so this + changes no runtime resolution — confirmed by rebuilding the CLI and re-running `--help` on + all three. The rename also brings them onto this package's measured convention: every other + root-level command class is exactly the PascalCase of its filename. `login.ts` keeps + `AuthLogin` — its `examples` were already correct (`$ os login`), so it is outside this + card's file surface; that lone remaining class-name holdout is reported, not swept. + + `environments.test.ts`'s `#10967` pin carried a deliberately self-retiring `EXCLUDED` entry + for each of these three files, asserting the defect was *still present* so the exemption + could not outlive its cause. This fix removed the last unresolved entry, that assertion went + red exactly as designed, and the three entries are retired — the map is now empty and all + three files are scanned by the main assertion like every other command source. +- 63da586: fix(cli): the "no stored credentials" error stops telling a stuck user to run `os auth login` (#11313) + + `readAuthConfig()` (`packages/cli/src/utils/auth-config.ts`) throws the one instruction a user + gets at the moment they are **already stuck**: they have no stored credentials, the command + they wanted has just failed, and this string is what tells them how to recover. It said + `os auth login`, which does not resolve — `login.ts` sits at the **root** of + `packages/cli/src/commands/`, so oclif's pattern-strategy loader registers it as `login`, and + no `auth` topic has ever existed. The second failure reads as "the tool is broken", not as + "a typo in a help string", which is why this is graded on its own terms rather than as another + stale-`examples` docs nit (#11221, #10967, #10927 were all `examples` arrays and README prose + — a user reading *ahead*). + + Measured against the built CLI (`packages/cli/bin/run.js`, after building the package and its + dependency closure) before the fix: `os auth login --help` → `Error: Command auth:login not + found.` (exit 2), `os login --help` → exit 0. Loading the built oclif `Config` enumerates 61 + registered ids, **zero** containing `auth`, and no `auth` topic — with `login`, `logout`, + `register`, `whoami`, `dev` and `serve` all present as the control that the zero is a real + absence rather than a broken probe. The message now says `os login`, the spelling `login.ts`'s + own `examples` already used. + + One further invocation in the same file is corrected in the same pass: + `AuthConfig.activeEnvironmentId`'s doc comment said `os projects switch`, and `projects` was + renamed to `environments` in v5.0 with no aliases (ADR-0006) — the same enumeration shows no + `projects` topic and no id containing `project`, while `environments switch` is registered. It + is fixed rather than excluded because the new pin scans the whole file, and an exclusion is how + a line stops being checked without anyone deciding to stop checking it. + + The pin (`packages/cli/src/utils/auth-config.test.ts`) asserts the **property**, not the new + spelling: every command invocation this file documents must resolve to an id the CLI actually + registers, with the id set re-derived from `src/commands/**` using oclif's own path→id + algorithm. A pin on the literal text `os login` would still pass on the day someone renames + `login.ts`; this one reds. It has two legs — the real `readAuthConfig()` driven into its real + ENOENT branch against a redirected `$HOME`, so what is checked is the message a user actually + reads, and a source-wide scan so a guidance string added to this file later is held to the same + property without anyone remembering to extend the pin. `environments.test.ts`'s `#10967` pin + reads `static override examples` via AST and structurally cannot see a thrown-error string, + which is why this class needed its own pin rather than an extension of that one. +- 7fa3851: fix(cli): the `os environments` family calls the entity an environment, not a project, in every string it prints (#12153) + + Per ADR-0006 the v5.0 rename `project` → `environment` has no aliases, and AGENTS.md + states "Project now only means the npm/monorepo sense". #10967 (PR #11227) renamed the + **command** (`os projects …` → `os environments …`) across these same five files; the + **entity noun** inside the strings oclif prints was left behind. A user ran + `os environments switch ` and the tool answered `✓ Active project: …`. + + 25 user-visible string literals in `packages/cli/src/commands/environments/` are swapped + to the post-rename noun. No behaviour, no flag or argument names, no exit codes, and no + `--format json` / `--format yaml` payloads change — those are produced by + `formatOutput(res, …)` straight from the control-plane response and are untouched. + + | where | count | printed by | + | --- | --- | --- | + | `static override description` | 5 | `os environments --help` | + | flag / arg `description` | 7 | each command's own `--help` | + | success-path console output | 9 | `list` · `bind` · `create` · `show` · `switch` | + | `examples` arg placeholder (`` → ``) | 3 | `os environments bind --help` | + | the `switch` id-not-found error | 1 | `os environments switch` on a bad id | + + The five `static override description` strings now read as + `content/docs/deployment/cli.mdx`'s command table has described them since the rename + ("List environments visible to the current session", "Provision a new environment", …), + so the shipped `--help` and the shipped docs agree for the first time. + + What is deliberately NOT renamed, because it is API surface in other packages rather than + CLI wording, and each needs its own decision: `client.projects.*` (the `@objectstack/client` + SDK method names), the `res.project` / `res.projects` response fields, the locals bound + directly from them, and the docblock comments in these files. +- 05181e8: **Fix:** the `Runtime:` row of the `os validate` / `os info` / `os compile` summary is no longer dropped on a stack with no plugins, and `MetadataStats` no longer counts a metric nothing prints (#11172). + + Two separate holes in one function, `printMetadataStats` in `packages/cli/src/utils/format.ts`. Both were measured against the real CLI (`bin/run-dev.js validate`, `NO_COLOR=1`) on a stack declaring nothing. + + **1. `Runtime:` vanished at zero.** The row was rendered *outside* the `sections` loop, as a standalone `if (stats.plugins > 0 || stats.devPlugins > 0)` after the loop closed, so the whole summary was: + + ``` + Data: 0 Objects + UI: 0 Apps + Logic: 0 Flows + Security: 0 Positions 0 Permissions + ``` + + with no `Runtime:` line at all. That is the same "reads as never asked, not as zero" shape #10504 and #10952 removed from the four sections — a stack that declares no plugins is indistinguishable from a summary that simply does not report on the runtime. It now prints: + + ``` + Data: 0 Objects + UI: 0 Apps + Logic: 0 Flows + Security: 0 Positions 0 Permissions + Runtime: 0 plugins + ``` + + `Runtime:` was **folded into the `sections` array** rather than fixed where it stood. Being outside the loop was not incidental to the defect: it is why #10952's `zeroFallback` mechanism was structurally unable to reach this row, and a zero case hand-rolled beside the loop would have been a second, un-enforced copy of the same invariant — while `zeroFallback` is a *required* field on the array's element type precisely so the next row cannot be added without naming what it prints at zero. The per-item `> 0` filter this row already applied is the same filter the loop applies, so the only thing that had to be carried across was its fragment style, and it is carried exactly: the shipped non-zero rendering stays `Runtime: 2 plugins, 1 devPlugins` — comma-joined, fully dim, lowercase item names — rather than being restyled into the sections' ` ` two-space shape. The ruling was about the row's presence at zero, not its typography. + + `plugins` is the row's zero signal: `devPlugins` is a dev-only overlay on it, so `Runtime: 0 devPlugins` would have reported the narrower fact and stayed silent about the broader one. A row with one non-zero peer still reports only that peer (`Runtime: 4 devPlugins`), exactly as `Security:` behaves. + + **2. `translations` was counted on every run and read by nothing.** `MetadataStats` declared `translations: number` and `collectMetadataStats` populated it with `count(config.translations)`, but no render path ever read it — a stack with 40 translation bundles reported them nowhere in the summary, at *every* value rather than only at zero. The field is removed implementation-first (zero readers); giving it a rendered home, in `UI:` or a new `i18n:` row, was considered and explicitly not taken. + + The invariant that replaces it is enforced from both ends: TypeScript already requires `collectMetadataStats` to populate every field `MetadataStats` declares, and a new pin requires every field it collects to reach the rendered output. Declared ⇒ collected ⇒ rendered — a metric counted on every `os validate` and shown nowhere cannot satisfy the chain, whatever it is called, so the pin fails for the next unread metric as well as for this one. + + **One externally visible consequence beyond the summary text.** All three commands spread the whole `stats` struct into their `--json` payload, so `os validate --json`, `os info --json` and `os compile --json` no longer carry a `stats.translations` key. That field was undocumented (the CLI docs describe `--json` for these commands but declare no payload shape for `stats`), carried no schema, and has no reader anywhere in the repo — a repo-wide search for `stats.translations` returns zero consumers. The other 18 keys are unchanged. +- e278a29: docs(cli): drop the `os studio` row from the README command table — the CLI ships no such command (#11180) + + `packages/cli/README.md`'s **Development** command table listed + `` | `os studio [config]` | Launch Studio UI with development server | ``. The CLI has no + `studio` command and has not had one: the oclif command set is pattern-derived from + `packages/cli/src/commands/**`, and loading the built CLI's own `Config` enumerates 60 + registered ids with **zero** matching `studio` (control: `dev`, `serve`, `login`, `logout`, + `register`, `whoami` are all present in the same enumeration, so the check is not vacuous). + Running it confirms the same from the outside — `os studio --help` exits 2 with + `Error: Command studio not found.` + + The row is deleted rather than rewritten. Studio is not reached by a CLI command at all — + it is served by the console at `/_console/studio` after `os dev` or `os serve`, both of + which the same table already lists — so a replacement row would reintroduce the category + error that made this one wrong: a Commands table is a list of commands, and a browser route + is not one. +- 82b65ec: fix(cli): `os serve` resolves app-declared optional service packages from the app, not the CWD (#11185) + + `serve` takes its config as an **argument**, so `objectstack serve /srv/app/objectstack.config.ts` + is a supported invocation and the app being served need not be the directory the operator + stood in. Every host-anchored optional load nevertheless used `process.cwd()` as its + resolution base, so with that invocation the CLI read the wrong `package.json`: a package the + app really does declare, and really does carry in its own `node_modules`, came back + `undeclared`, fell through to the framework-side fallback, and boot died — + + ``` + Cannot find package '@objectstack/service-cluster': the host app does not declare it. + host app: /tmp/os-neutral-cwd-jXHXdF ← the CWD, not the app + (fallback resolution also failed: Cannot find package '@objectstack/service-cluster' + imported from …/packages/types/dist/node.mjs) + ``` + + Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate exits 1 ⇒ the + whole stack cannot start. This is the same class as cloud#1013 and #10645 with the base wrong + for a different reason: those fixed the **importer** at these load sites (bare `import()` → + `importFromHost`); this fixes the **base** that importer is handed. + + `serve` now resolves the config path and the app root in one call (`anchorServedApp`), so the + anchor cannot be written too late or left out by a future author — the absolute config path + every later line needs is produced by the same call that sets it. Every host-anchored load in + the file defaults to that root, which is what generalises the repair to the next app-declared + optional service rather than fixing this one instance. The alternative route — declaring + `@objectstack/service-cluster*` in `packages/cli`'s own manifest — was rejected: it would make + the open-core CLI take a published dependency on packages it never imports, still leave every + third-party or future optional service broken, and change nothing for an app whose config is + addressed by path. + + The adopted root is the config's directory **only when that directory holds a `package.json`**, + and the CWD otherwise. `readHostDeclaration` reads a manifest — reachability is deliberately + not the contract (#4719) — so a directory with no manifest declares nothing and anchoring + there could only turn a working boot into an `undeclared` refusal. No layout that resolves + today resolves differently after this. + + The #4719 declaration gate is untouched: a package present in the app's `node_modules` but + absent from its `package.json` is still refused. The refusal's remedy (`host app: …`) now + names the app being served instead of an unrelated directory the operator happened to be in. +- a4cb781: **Fix:** `os serve`'s host importer now states its own resolution base, so a package the served app does not declare resolves from `packages/cli` instead of from `@objectstack/types` (#11157). + + `createHostImporter` has two legs. The **declared** leg resolves out of the served app's `node_modules` (#4719; #11185 fixed *which* app that is). The **undeclared** leg falls back to "the importing package's own resolution" — and which package that is depends entirely on where the `import()` is physically written, because Node ESM resolves a bare specifier against the module containing the call. #10943 turned that into an explicit parameter, `options.fallbackImport`, so a caller can hand in its own `import()`. `@objectstack/verify` (`bootStack`) and the `packages/qa/dogfood` enterprise probe both pass theirs; `serve`'s `importFromHost` did not, so it advertised the CLI's resolution and actually used `@objectstack/types`', which under a pnpm-isolated layout sees only `@objectstack/spec`. + + **Measured accept-set delta**: the undeclared fallback now reaches exactly what `packages/cli` itself declares, and nothing else. Re-measured with `import.meta.resolve` from a probe in each package — `chalk`, `@objectstack/plugin-auth` and `@objectstack/plugin-audit` resolve from `packages/cli` and not from `@objectstack/types`; every specifier `serve` itself routes through the helper (`@objectstack/service-cluster` and its drivers, `@objectstack/service-i18n`, `@objectstack/organizations`, `@objectstack/service-ai`, `@objectstack/service-ai-studio`) resolves from **neither**, which is why this was harmless in every shape that ships today. The #4719 declaration gate on the declared leg is untouched: a package that is merely reachable is still refused, and no app gains a way to load something it has not declared. + + **Two user-visible consequences.** A `plugins: [...]` entry naming a package the app does not declare but the CLI ships now resolves through the host importer rather than a separate local `import()` — same module, one attempt instead of two. And the undeclared-package diagnostic drops its "the caller did not pass `fallbackImport`" note, which `@objectstack/types` emits only for callers that withhold their base; the note existed so this gap would report itself, and it has now been closed rather than silenced. + + `Serve.importConfigPlugin`'s three-branch shape collapses to two in the same change. The undeclared branch kept a local `import()` *because* the helper's fallback resolved from the wrong package; with the base threaded, that branch and the re-entry branch are the same call, so the declaration is read once — by `readHostDeclaration` inside the helper — instead of being asked there and again here. Behaviour was measured case by case first: the app's declared copy still wins, a declared-but-uninstalled package still reports the install remedy, a package present-but-throwing still propagates as a crash (both paths gate on the one shared `isModuleNotFoundError`), and a package resolvable nowhere still produces the #4719 "declare it in that app's package.json" text. +- 87d83ac: `os build`, `os validate` and `os init` say how many diagnostics they withheld + + Nine more renders across the three authoring commands cut their list at a fixed + cap and printed nothing saying so — the `--strict-body` refusal list, the + author-time rule failures, the undeclared-authoring-key findings, the + access-matrix drift, the package-doc errors, and both halves of `os init`'s + scaffold self-test. The defect is the **silence**, not the cap: truncated + output that carries no notice is indistinguishable from complete output, so an + author who reads it and sees no further problems has read a list that stopped + early. Two of them even stated the true total in their own header and then + showed fewer rows, so the report gave two numbers that disagreed and explained + neither. + + On the gating lists it also undoes the thing `os validate` went out of its way + to provide. Its own comment records why every failing rule reports at once: + "the command used to exit at the first failing gate, so an author with three + unrelated problems fixed them in three round trips and could not see how deep + the hole went". Past the cap that is exactly what came back, one cap-width at a + time, with each round of fixes revealing a new batch that reads as fresh + breakage. + + Every cap stays. Over it the output now names the exact remainder: + + ``` + ⚠ … and 30 more author-time rule failure(s) not shown (50 of 80) — re-run with --json for the full list + ``` + + **The pointer is verified per site, and two notices deliberately omit it.** + `--json` publishes each of these lists at the very exit whose text face carries + the notice, so re-running really does return the complete set. `os init` + declares no `--json` flag at all, so both of its notices state the remainder + and name no remedy — a notice whose remedy does not work is worse than a silent + cut, because it sends the author down a path that returns the same truncated + view. + + At or under a cap, nothing new is printed and the rendering is byte-for-byte + what it was. +- c770bda: chore(cli): pin the multi-org runtime name `os doctor` prints, so a roster rename cannot drift it silently (#12464) + + `doctor.ts`'s `TENANCY_POSTURE_FIX_HINTS` repeated `serve`'s `isolated` posture + sentence verbatim, carrying its own bare `@objectstack/organizations` literal + under no check at all. PR #12463 single-sourced and pinned every operator-facing + occurrence in `serve.ts`; this copy was outside that card's file surface and was + correctly left alone. The defect it left behind is the same class: a roster-key + rename would leave `os doctor` naming a package that boot no longer resolves, + **with every gate green** — the roster pin only ever sees the declaration, and + nothing read this hint table's text. + + The `isolated` hint now interpolates a module-level `ORGANIZATIONS_RUNTIME_PKG` + in `doctor.ts`, and a new sibling test pins it on two legs: the **rendered** + bullet compared whitespace-included against text built from that declaration + (the #12463 shape — asserting what the operator sees, never that a constant + appears in source), and a **roster** leg asserting that declaration IS a key of + the spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES`. The roster leg is the load-bearing + one: without it the hint and its expectation move together under a rename and + nothing goes red. + + **This deliberately does not single-source the spelling, and the constant's + docblock says so at the site.** The literal is still declared three times (the + roster key, `Serve.ORGANIZATIONS_RUNTIME_PKG`, and now this const). The roster + cannot supply the name — it is keyed BY package name, its row type carries no + `package` field by design, and its own header records that it is "not a + resolution registry" — and importing `serve`'s export into a diagnostic command + would be a worse coupling than the duplication it removes. What changes is that + this copy can no longer drift in silence. The duplication ends properly when a + shared tenancy-hint table lands (tracked at #12492); the docblock carries that + deletion condition. + + **No behaviour change.** The declared value is byte-identical to the literal it + replaces and the rendered bullet is unchanged. +- 50fb191: `os generate` writes every scaffold as `NAME.TYPE.ts`, with the infix read from + the metadata type registry instead of the harness + + `os g object customer` wrote `src/objects/customer.ts`. The metadata loader + discovers files by globbing each type's own `filePatterns` from + `DEFAULT_METADATA_TYPE_REGISTRY` — `MetadataPlugin._loadFromFileSystem` does + this for **every** registered type, and it is the default `eager` bootstrap + whenever no compiled artifact is configured. Measured across the seven + generators: the bare `NAME.ts` the harness wrote matched **zero** of those + patterns for all seven, while `NAME.TYPE.ts` matched exactly one, every time. + A scaffold that matches nothing still type-checks, still passes `os validate` + and still publishes, and is then never loaded, with no diagnostic at any step + — the silent-strip shape ADR-0063's retirement of `os g agent` closed. + + The previous release closed this for `skill` alone, through an optional + per-generator filename override, on the belief that the other six types were + not filesystem-discovered. The loader says otherwise, so the override is + retired rather than copied five more times: the filename is now derived from + the type's declared pattern, which is also why it reads the pattern rather than + interpolating the type key — `email_template` declares `*.email-template.ts` + and `external_catalog` declares `*.external-catalog.ts`, so an interpolated + infix would re-create the same invisibility for the next generator added. + + Now written: `customer.object.ts`, `customer.view.ts`, `approve.action.ts`, + `customer.flow.ts`, `sales.dashboard.ts`, `crm.app.ts`, + `lead_qualification.skill.ts`. The barrel `index.ts` line is derived from the + filename that was actually written, so it names the real module. + + **Existing files are not renamed and keep working.** A `NAME.ts` file already + on disk matched no `filePatterns` entry before this change either, so nothing + it relies on moves: if your app loads it through the barrel `index.ts` the + generator wrote, that import is untouched. Rename it to its type's pattern if + you also want the loader's own glob to find it. +- 68c5dba: fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624) + + `os lint` computes i18n coverage and runs the authoring-rule registry in a + single pass over the same stack, and for the `flows` translation group the two + halves pointed opposite ways: + + | the author does | which rule fires | what it says | + |---|---|---| + | omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X | + | adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it | + + Measured on one stack, one run: omitting produced **4** `i18n/missing-flow` + findings and 0 liveness findings; authoring produced 0 demands and **2** + `liveness-planned-property` findings ("sets `flows` but this translation + property is planned"). There is no per-rule suppression in `os lint`, only + `--skip-i18n`, which silences the entire `i18n/missing-*` family — so the + author's only escape cost them every other coverage signal. Under + `--i18n-strict` the demand side is an **error**, so a project could be forced + to author keys it is then warned for. + + ⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner + reads the group, so a translated wizard string is stored and never shown — the + failure mode `validationMessages` was removed in 17.0.0 for. The premature half + is the demand. + + **The fix.** `collectExpectedEntries` — the single definition of what is + translatable at all, shared by the coverage gate and the `os i18n extract` + skeleton — now leaves out any translation group the liveness ledger warns + authors for authoring. It reads that set from `@objectstack/lint`'s new + `authorWarnedProperties(type)`, which returns the very warn-map + `lintLivenessProperties` iterates, so the demand side and the warn side cannot + drift into disagreeing about the same keys again. + + Two properties fall out of reading the ledger rather than switching on `flows` + by name: the bucket **turns itself back on** the day an objectui screen-flow + runner lands and the row flips to `live` (no flag, no follow-up edit), and any + future group that acquires an `authorWarn` is covered on the day it is marked + rather than re-opening this collision one group at a time. Today `flows` is the + only such group — pinned as an equality so a second one goes red instead of + shipping. + + No other bucket changes: `objects`, `apps`, `pages`, `dashboards`, + `globalActions` and `metadataForms` are all `live` and are reported exactly as + before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` + + `authorWarn: true`. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- 134a506: fix(cli): `os init` scaffolds its starter object as `_item.object.ts`, the spelling the registry declares (#11598) + + `objectstack init` wrote its starter object to `src/objects/_item.ts` + while `DEFAULT_METADATA_TYPE_REGISTRY` declares the `object` type as + `**/*.object.ts` / `.yml` / `.json`. Measured with `node:path`'s `matchesGlob` + against the registry read at runtime: `src/objects/my_app_item.ts` matched + **zero** of the three patterns, `src/objects/my_app_item.object.ts` matches + exactly one. Both `srcFiles` tables (the `app` and `plugin` templates) and the + barrel specifier they emit now carry the type infix. + + **This was a naming inconsistency, not breakage — measured, not assumed.** A + scaffolded project declares its objects in code (`import * as objects from + './src/objects'` → `objects: Object.values(objects)`), so the object reaches the + stack through the barrel's *module specifier*, and `os dev` / `os serve` then + boot from the compiled `dist/objectstack.json` rather than by globbing source. + Three real scaffolds were compiled with the real `os compile` to establish it: + the old-spelled file **did** land in the artifact (so nothing was ever silently + skipped — this is not the #10359 silent-strip shape), a `*.object.ts` file + dropped into `src/objects/` but *not* re-exported from the barrel did **not** + land in it (so the registry glob was never on this load path), and the new + spelling lands identically. + + What it *was*: one CLI teaching two spellings for one metadata type. After + #11071 an author who runs `os init` and then `os g object customer` gets + `src/objects/my_app_item.ts` beside `src/objects/customer.object.ts` in the same + directory, from the same CLI. The registry spelling is the authority — the same + convergence #11071 settled — and it is already what `create-objectstack`'s blank + starter ships (`note.object.ts`), what the examples use + (`app-crm/src/objects/account.object.ts`), and what the getting-started docs + list as the house convention two lines under the callout that described the old + name. + + **Existing scaffolded projects need to do nothing.** The old filename still + loads exactly as it did — the barrel imports it by specifier and the filename is + not consulted. Renaming `src/objects/_item.ts` to + `src/objects/_item.object.ts` (and the matching `from './_item'` → + `from './_item.object'` in `src/objects/index.ts`) is an optional + consistency cleanup, not a migration: it changes nothing about how the project + builds, boots or behaves. Only newly scaffolded projects get the new name. +- 93949f1: docs(cli): `invocation.ts`'s `isProcessEntry` doc now names its two siblings + + The three-copy `argv[1]`-vs-`import.meta.url` predicate (`isProcessEntry` here, + `isEntrypoint`/`invokedAs` in this repo's `scripts/invoked-as.mjs`, and + objectui's own `scripts/invoked-as.mjs`) carried the "change one, change the + others" sync obligation in only one of the three copies — objectui's. Neither + objectstack copy pointed at the other two, so an agent editing either file + here had no way to discover that a third copy exists elsewhere (#12013). + + Both objectstack copies were otherwise correct and are not changed in + substance; only a reciprocal pointer is added to each, comment-only. +- a2ec377: Stop naming a package nobody can install: `@objectstack/framework` is not a real + package, and the multi-org remedy now says it is not publicly obtainable + + Four `@objectstack/` names appear in this repo's published docs and runtime text + without this repo building any of them. Measured against the public npm registry + (unauthenticated `GET https://registry.npmjs.org/@objectstack%2F`, with + `@objectstack/spec` and `@objectstack/cli` as positive controls so a 404 is a + fact about the name and not about access), they are **not one population but + three**: + + - `@objectstack/framework` — **404, and fabricated.** Unlike the others, nothing + in this tree describes it as enterprise, cloud, or private; it is presented as + the *default public* install. There is no umbrella package and there never was. + - `@objectstack/security-enterprise` (404) and `@objectstack/organizations` + (404) — **real, and deliberately not public.** This tree calls them + "closed-source" and "cloud-private" in a dozen places, and + `PLATFORM_CAPABILITY_PROVIDERS` declares `security-enterprise` with + `edition: 'enterprise'`. Their 404 is the caveat npm's API carries for any + private package, not evidence of fabrication. + - `@objectstack/service-tenant` — **published, at 4.1.0**, exactly as + `platform-object-names.ts` describes it. Untouched. + + What changes: + + - **`@objectstack/console`'s README** no longer opens with + `pnpm add @objectstack/framework`. The mechanism it described is real, just + misnamed: `@objectstack/cli` declares `@objectstack/console` as a dependency + and both ship at one version from the Changesets `fixed` group, so any app + that installs the CLI — every `npx create-objectstack` scaffold does — already + gets a version-matched Console. The instruction is corrected rather than + deleted, so the reader is left with something they can run. + - **`serve`'s multi-org fail-fast** kept telling an operator to add + `@objectstack/organizations` to their app without saying the runtime ships + only with an enterprise/cloud subscription. That is the un-followable "add it + to your dependencies" that framework#3366 exists to make legible. The remedy + now states it, so an operator without a licence can see that the two bullets + below it are their actual path. The declared-but-unresolvable branch is + unchanged — that operator does have the package. + + No behaviour changes: boot outcomes, exit codes and the posture wall are + untouched, and `@objectstack/security-enterprise`'s install hint is deliberately + left alone — it already names its edition boundary, and the test pinning it is + strengthened to assert that it keeps doing so. +- 6c6157a: `os migrate plan` / `apply` examine the object set the composed host DECLARED, and report the boundary when they cannot + + A composed host stack (#12938) registers its plugins for their DECLARATIONS: `init()` runs, `start()` is suppressed. The pass that hands every registered object to its driver — the one that fills the `managedObjectFields` map `detectManagedDrift()` diffs — lives in `ObjectQLPlugin.start()`, and a host that brings its own `ObjectQLPlugin` (under the framework's own plugin name, so the CLI's capability injector de-dups against it) DISPLACES the standalone one, since duplicate registration overwrites by name. The result was a boot where no `ObjectQLPlugin.start()` ran at all: every host plugin declared its objects, and not one reached a driver. + + Measured on ObjectStack Cloud's staging control plane: 36 host plugins composed, ~80 `sys_*` tables declared, **8** examined — all eight belonging to the single service that provisions its own tables from a `kernel:ready` hook rather than relying on that pass. Every consumer-visible signal was green, and `Physical schema is in sync with metadata` was one composed plugin away from printing over seventy unexamined tables. + + Two changes: + + - **The composed boot now drives that pass itself**, over the deferral it already armed: `engine.syncObjectSchema(name)` per declared object, which reaches `SqlDriver.initObjects` exactly as the suppressed `start()` would have. A plan still writes nothing — the deferral records the create-table work instead of running it. + - **`plan` / `apply` report what they could NOT examine.** `--json` payloads gain `composition.coverage` (`registeredObjects`, `examinedObjects`, `unexaminedObjects`, and per-reason counts: federated, unbound, on another datasource, on a driver without schema registration, refused). When `unexaminedObjects > 0`, the human output refuses the unqualified "in sync" line and says the plan is PARTIAL instead. A consumer gate asserting coverage should read `composition.coverage.unexaminedObjects` — `managedTables` alone cannot tell a small deployment apart from a mostly unexamined one. + + `@objectstack/driver-sql`: `initObjects` no longer calls `ensureDatabaseExists()` while DDL is deferred. It is the one line there that can write — `mkdir -p` for a sqlite parent directory, and on Postgres/MySQL a `SELECT 1` that CREATEs the database on `3D000` / `ER_BAD_DB_ERROR` — and under the deferral there is no DDL for a database to exist for. `flushDeferredSchemaDdl` clears the flag before re-entering, so the confirmed `os migrate apply` still ensures the database ahead of the first `CREATE TABLE`. + + A project with neither an `objectstack.config.*` nor a compiled artifact is unchanged: it composes nothing, carries no `composition` key, and diffs the same five data-stack tables it always did. +- e6fd1ca: fix(cli): name the missing build output instead of reporting "command not found" (#12964) + + In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s + every command module while it builds its manifest, every one of them fails, and the run + ends on + + ``` + Error: command i18n:extract:… not found + ``` + + with exit 2 — while the command file is right there in `src/commands/`. A command whose + module will not load is indistinguishable, to `Config.runCommand`, from one that does not + exist, so the only cause the reader is handed is the one cause that is definitely not + true. + + `packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its + own gates and e2e suites, and not part of the published package — now collects oclif's + module-load warnings and, when that failure was caused by a package this repo builds, + prints the attribution and the single command that fixes it ahead of oclif's report: + + ``` + objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to + LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'. + The unmet precondition is @objectstack/spec's build output, not the invocation. + objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec + ``` + + Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the + module that already answers this question for the gates that shell out to the CLI, so + there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a + command that really is missing keeps oclif's reporting exactly as it was — the diagnosis + requires BOTH oclif's "not found" and a module-load failure naming a workspace package. +- 2082180: fix(cli): the `sys_secret` reference union asks the engine for family 3 instead of trusting every host to remember (#12804) + + Family 3 of the cross-producer reference union — handles held at a datasource + artefact's `external.credentialsRef` — was pure over the artefacts its caller + supplied. `#12758` landed the producer half (`registerDatasourceDef` retains + `external.credentialsRef`, `ObjectQL.listDatasourceDefs()` reads it back), so + the engine could answer the question; the union never asked it. Measured on the + pre-change tree: a datasource registered in code with a bound credentials + handle, with `declaredDatasources: []`, produced a union reporting + `complete: true` while omitting that live handle. A complete-looking union that + is short one live credential is the precondition failure `#8103`'s deletion + predicate rests on. + + The union now assembles family 3 from **three** sources — persisted + `sys_metadata` rows, the definitions the engine holds, and the host's declared + list — as a union, not a replacement. Neither code-side source dominates: the + engine indexes only what was REGISTERED on the runtime, so a config file + nothing ever installed is invisible to it, while a host's list can omit a + datasource a package manifest installed behind its back. + + The declared gap is **re-scoped, not removed**. `declaredDatasources: + undefined` still refuses the whole union, because the residue it covers is + still unreachable: a datasource declared in code that nothing ever registered + reaches neither `sys_metadata` nor `listDatasourceDefs()`. A second refusing + shape joins it — an engine slice that cannot list its definitions gaps the + family rather than contributing an empty answer, symmetric with the host's + `undefined`. In both cases `[]` remains the way to state "there are none". + + `SecretReferenceEngineLike` gains `listDatasourceDefs?()` as an **optional** + member, so every slice that satisfied the port before still satisfies it. The + three prose sites that `#12758` falsified are rewritten rather than trimmed: + the retired mechanism was "the engine drops `credentialsRef`", and the live one + is "the engine's index covers only what was registered, so the residue is + invisible until the host is asked". The operator-facing gap message carries the + new mechanism, and a test pins that it does not carry the old one. + + Bump kept at `patch`, matching `#12663` which created the module: nothing here + reaches the package's entry barrel — `packages/cli/src/index.ts` names no + symbol of this module, and no consumer outside `@objectstack/cli` imports it. +- 9bed0b0: fix(cli): the startup banner names a contested flow name and says which definition is armed (#12028) + + `os dev` / `os start` print an automation summary that reads binding STATE off the + live engine, because a flow that failed to arm emits no log line to go looking for + and the boot-quiet stdout window swallows the engine's own `warn` narration. That + summary was silent about the one failure it could not express as a count. + + The engine's flow map is keyed by BARE name. When a packaged flow and a + runtime-authored `sys_metadata` overlay both claim one name, ADR-0005 precedence + arms one and the loser is not in the map — so it is not in `listFlows()`, not in + `getFlowRuntimeStates()`'s rows, and therefore not in any number the banner + prints. `3 flow(s), 3 bound to triggers` was a true sentence about a set that did + not contain the definition the operator had just edited, and nothing on the banner + said otherwise. #11997 gave the engine the receipt (`getShadowedFlows()`, plus + `armedFrom` / `shadowed` on each runtime-state row) and the automation plugin + warns from it at `kernel:bootstrapped` — but that is a `logger.warn`, which is + exactly the channel this banner exists to work around. + + `collectAutomationSummary` now reads that receipt through a probe feature-detected + exactly like the `getTriggerBindingAudit` one beside it, and the banner prints one + line per contested name carrying all three facts an operator needs: + + ``` + ⚠ flow 'send-welcome' is claimed by 2 definitions — a runtime-authored row + (sys_metadata) is ARMED, 1 shadowed (ADR-0005 overlay precedence; only the + armed definition dispatches) + ``` + + Naming which body is armed is the point: a line reporting only the count tells an + admin something is wrong and withholds the answer they are standing there to get. + + Silent on every healthy boot — no contested name, no line. This banner is read on + every start, and a warning that also fires when nothing is wrong is one readers + learn to skip. Both directions are pinned on what the banner RENDERS, absence + included. +- f86839d: refactor(cli): `serve` resolves `@objectstack/organizations` through one declaration the spec roster pins (#11614) + + `PLATFORM_PLUGIN_WIRED_RUNTIMES` (`packages/spec`) is the provenance roster for + `plugins[]`-wired out-of-repo runtimes, keyed by npm package name — the single + machine-readable answer to "is this `@objectstack/*` package real, and where + does it ship from?". It exists because a **fabricated** `@objectstack/framework` + sat next to the real `@objectstack/organizations` in published docs for months, + indistinguishable by inspection (#10921). + + `serve` is the only runtime that prints one of those names AT OPERATORS — the + install remedy, the fatal refusal when a walled tenancy posture cannot load the + multi-org runtime, and the degraded-boot warning. It spelled the package as a + bare literal at the resolution site, under no pin at all, so the roster and the + name `serve` actually resolves could diverge in silence: rename the roster key, + or mistype the literal, and every gate stays green while boot reaches for a + package that does not exist and the fatal message tells the operator to install + it. + + The name is now declared once, as `Serve.ORGANIZATIONS_RUNTIME_PKG`, and both + resolution-path uses read it — `importFromHost(…)` and the `readHostDeclaration(…)` + that decides which of the two absence remedies to print. A drift pin in + `serve-capability-vocabulary.test.ts` (the suite that already holds the two + rosters to each other) asserts that value is a roster **key** and that its row is + the `enterprise` edition. + + **Load semantics are untouched.** Which postures load the runtime, the two-stage + import/mount failure classification, and what `OS_ALLOW_DEGRADED_TENANCY` does + and does not cover are all exactly as they were; the roster is deliberately not a + resolution registry and is not consulted at boot. This single-sources the + spelling and nothing else, so there is no behaviour or output change — the + declared value is byte-identical to the literal it replaces. +- 07fd1ac: chore(cli): spell the multi-org runtime from ONE declaration, with the static keeping its name (#12579) + + `Serve.ORGANIZATIONS_RUNTIME_PKG` carried a second copy of + `@objectstack/organizations`, beside the declaration in + `utils/tenancy-posture-hints.ts` that `os doctor` reads. That duplication was + deliberate: the host-anchoring sweep in `serve-cluster-host-resolution.test.ts` + resolved serve's organizations `import()` through that static and needed a + LITERAL in that file — written as a re-export, the specifier stopped resolving + and the load dropped OUT of the swept population instead of failing inside it + (#11614's silent-vacuity mode, which #12492 hit and measured). That constraint + died at `1ca763b60` (#12533), which taught the sweep to follow an import alias + into a sibling module of the same package. + + The static is now assigned from the shared const. It keeps its NAME — the roster + pin (`test/serve-capability-vocabulary.test.ts`), the sweep and the + rendered-message pins all address `Serve.ORGANIZATIONS_RUNTIME_PKG`, and only the + spelling moved — and the equality assertion that kept the duplication CHECKED + (site 8 of `serve-organizations-message-spelling.test.ts`) retires with its + subject, in the same change. The gap is still never closed in the other + direction: `os doctor` must not depend on a `serve` export to spell a package + name (#12464's coupling ruling, untouched). + + **No behaviour change, measured rather than argued.** Every operator-facing + string that names the runtime — both install-remedy branches, the ADR-0093 D5 + fatal refusal, the degraded-boot warning, the stage-2 mount refusal and the + tenancy-posture fix list — was rendered before and after the change and compared: + byte-identical, 5257 bytes, sha256 `d545248b91e52d05…`. +- 27b6902: chore(cli): spell the multi-org runtime from its one declaration in `serve`'s operator-facing prose (#12151) + + `serve` prints `@objectstack/organizations` at operators in five places: the + two-branch install remedy, the ADR-0093 D5 fatal refusal when a walled tenancy + posture cannot load the runtime, the degraded-boot warning, the stage-2 mount + refusal, and the `isolated` posture description in the tenancy-posture fix list. + #11614 single-sourced the name `serve` RESOLVES onto + `Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the + spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster, but the sentences kept their + own copies — so a roster-key rename would leave operator instructions naming a + package that no longer exists while boot reached for the new one, with every gate + green (the roster pin only sees the declaration). + + All five now interpolate the constant, and a new test asserts what they RENDER + rather than that the constant appears in the source — the affected line compared + whitespace-included against text built from the same declaration. The message + bodies moved into pure formatters (`formatOrganizationsInstallRemedy`, + `formatOrganizationsAbsentFatal`, `formatDegradedTenancyWarning`, + `formatOrganizationsMountFatal`), the seam shape `resolveTenancyPostureOrRefusal` + in the same file already uses, so the rendering is reachable without spawning a + boot; `chalk` and the `process.exit` stay at the call site. + + **No behaviour change.** The declared value is byte-identical to the literal it + replaces, and the rendered output was verified byte-for-byte against the + pre-change expressions across every branch (both remedy kinds, both walled + postures, `mountCode` present and absent). The three comments in `serve.ts` that + legitimately name the package are untouched — the tempting "no bare literal + outside the declaration" source scan is deliberately not built, since it would + have to exclude comments and that shape is easy to get wrong. +- 4f9df8f: fix(cli): `os serve` publishes the port it BOUND, not the one it was asked for (#13062) + + The three channels `serve` announces an address on — the `objectstack:listening` + IPC message, the ready banner's `API:` row and `runtime..json` — + were three outputs of one number, and that number was the port the operator + requested. For every port but one the requested and the bound value coincide, so + this stayed invisible; `0` is the value where they cannot coincide. + `MIN_PORT = 0` is legal on purpose (`utils/port-contract.ts`: 0 is "a REQUEST, + not an error" — `listen(0)` binds a kernel-assigned port). + + FROM (`os serve --port 0`): IPC `{ port: 0, url: 'http://localhost:0' }`, banner + `API: http://localhost:0/`, `runtime.env_local.json` `"port": 0` — three + channels naming an address nothing was listening on, with nothing erroring. + + TO: all three name the port the HTTP server actually bound, read off the + transport's own `IHttpServer.getPort()` (the contract member that already + promises "the real bound port — in particular when `listen(0)` requested an + ephemeral port"). The same repair covers a bind that walked past a port taken + between `serve`'s probe and the transport's `listen()`. + + Unchanged for every other port: when the requested port is the bound one — which + is every ordinary boot, including one that dev-auto-shifted off a busy port — + all three channels publish exactly what they published before. A boot with no + HTTP server, or a transport that does not implement the optional member, also + falls back to the previous value. +- 1c0b9a5: refactor(cli): single-source the tenancy posture hint table `os serve` and `os doctor` both print (#12492) + + `serve.ts` and `doctor.ts` each declared their own `TENANCY_POSTURE_FIX_HINTS`, + and the two tables were **byte-identical** — sha256 `97497ea8…` on both, modulo + the expression spelling the package name — under no cross-check of any kind. + + The `isolated` entry at least carried a package literal the spec-owned + `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster could be pinned against (#12464 / PR + #12496). **`single` and `group` were the worse half**: they touch no roster, so + nothing anywhere could ever have noticed them drift apart. A reword of one + command's copy left the other describing the same posture differently to the + same operator, with every gate green. + + Both tables now come from one CLI-internal module, + `packages/cli/src/utils/tenancy-posture-hints.ts`, which also holds the single + `ORGANIZATIONS_RUNTIME_PKG` declaration. `Serve.ORGANIZATIONS_RUNTIME_PKG` + becomes a **re-export** of it rather than a second declaration, keeping the + stable handle `serve`'s boot path and the roster pin already address; the + module-local const #12464 added to `doctor.ts` is **deleted**, which is the + deletion condition that const's own docblock recorded against this card. The + literal is now declared twice (the roster key and this module) instead of three + times. + + Each command keeps its **own bullet assembly** — doctor renders + `• OS_TENANCY_POSTURE=

    ` inside a health-check fix list, serve renders + `• set OS_TENANCY_POSTURE=

    ` inside a FATAL refusal, at different + indents. Only the table was ever duplicated. + + The two sibling spelling pins are **retargeted, not dropped** — including the + roster leg, which is the load-bearing one (a rename of the package value leaves + the rendered leg green; only the roster leg catches it). Each gains one new leg + asserting that every posture bullet, `single` and `group` included, renders the + **shared** table's entry verbatim — so a command that re-grows a local copy goes + red instead of drifting in silence. + + **No behaviour change.** The rendered text is byte-identical before and after + for all three postures at both commands, verified by capturing both commands' + full rendered fix lists on `origin/main` and on this branch and diffing them. +- 6c6157a: `os migrate plan` / `os migrate apply` exit when their work is done + + Measured on ObjectStack Cloud's staging control plane, inside `docker run --rm`: the CLI finished in 4.3 seconds and printed its own `Graceful shutdown complete`, and the run was cancelled by hand **78 minutes later** — the shell's next statement never ran, so it was still blocked on that one `docker run`. + + The composition these commands perform (#12938) registers a host's plugins for their DECLARATIONS: `init()` runs, `start()` is replaced with a no-op. Anything a host plugin arms during Phase 1 whose release would have been installed by Phase 2 — an interval, a pool, a watcher, a `kernel:ready` hook that starts a dispatcher — has no release path at all, so the event loop never drains while the kernel reports a clean shutdown. + + Both commands now end the process deliberately once their document is written, after the kernel teardown they already ran. Chasing the handle instead would mean auditing host code this repo cannot see, which is the same argument that made the composition declaration-only in the first place. `stdout` and `stderr` are drained before the exit, so a `--json` payload on a pipe is not truncated — and the drain itself is bounded, so a pipe whose reader has gone away cannot become a second way for the command not to return. + + Failure paths are unchanged: `this.exit(n)` throws an oclif `ExitError` that oclif's own handler already turns into a `process.exit`. +- 7b97895: `os dev` / `os start` / `os serve` refuse an impossible port at their own door, from one shared contract + + The port range, the reader that turns operator text into a port, and the refusal + prose now live in a single module (`packages/cli/src/utils/port-contract.ts`) + that all three commands import. Before this, only `serve` validated: a value + typed at `dev` or `start` travelled to the spawned `serve` child and was refused + one process later, under the name of the CHANNEL it arrived on rather than the + spelling the operator had used. + + **FROM → TO — what changes, stated precisely.** + + - **The end-to-end accept set does not change.** Since #12662 every value listed + below already ended in a refusal; what moves is WHERE the refusal happens and + WHAT it names. Measured before and after by driving a table of 18 port texts + through all three real commands on all three channels (`--port`, `$PORT`, + `$OS_PORT`) — 162 runs per side, every row identical in verdict. Values that + boot today still boot, on the same port: ` 3000`, `3000 `, `+3000`, `08080`, + `3e3`, `0x0BB8`, `3000.0`, `3000abc` and `0b111` are all accepted, exactly as + before, and `parseInt`'s tolerance is deliberately preserved — a strict-decimal + reader would refuse six values that start a server today. + - **The refusal moves earlier: from the `serve` child to the parent's own door, + before anything is spawned and before any socket exists.** + - **The refusal names the operator's spelling.** + - `PORT=abc os dev` — FROM `✗ Invalid port: --port "abc"` TO + `✗ Invalid port: PORT="abc"`. + - `OS_PORT=abc os dev` — FROM `✗ Invalid port: --port "abc"` TO + `✗ Invalid port: OS_PORT="abc"`. + - `os start --port 99999` — FROM `✗ Invalid port: PORT="99999"` TO + `✗ Invalid port: --port "99999"`. + - `os serve` is unchanged in every respect; it already named what it could see. + - **`os dev --port ""` is unchanged: still dropped, not refused.** An empty + string is falsy, so `dev` forwards nothing and the child resolves its own + default — measured, and preserved deliberately, because refusing it would + narrow a published command's accept set. + + No new flag, no new environment variable, no new configuration key. The range is + declared in exactly one place in the repository; `os start`'s `--port` did NOT + gain `Flags.integer({ min, max })`, because that bound would be a second copy of + the range and, measured against `@oclif/core` 4.13.3, neither a flag's `parse` + nor an integer `min`/`max` runs over a value supplied by a flag's `default` — + which is how `$PORT` and `$OS_PORT` reach the CLI. +- 9981f31: Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) + + `@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` + returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and + `os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: + `RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares + `z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this + key's vocabulary, and the disagreement survived because nothing ever executed + the schema — `RestServer` cast its config instead of parsing it. + + `StandaloneStackResult['api']` now declares, and the factory now emits, + `projectResolution: 'auto'`. + + **Behaviour on the routing path is unchanged, and that is measured, not assumed.** + Every reader that acts on this key is gated on `enableProjectScoping` first: + `RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` + mounts `[versionedBase]`, and the Dispatcher plugin's two + `enableProjectScoping && … === 'required'` guards short-circuit. With scoping off + the strategy really is moot for routing — which is why this migrates the value + rather than teaching the enum a fourth member. + + **One reader is not gated, and that is the user-visible fix.** `RestServer`'s + discovery handler copies `api.projectResolution` into + `discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares + that field as the same three-member enum. So `GET /api/v1` on every `os serve` + boot advertised a payload the platform's own schema rejects. Clients that + validate discovery — or switch on `scoping.resolution` — now receive a declared + value. + + Both halves are pinned rather than described: `merge-boot-config.test.ts` parses + the CLI's real boot block against `RestApiConfigSchema` and against the discovery + field's enum, and `standalone-stack.test.ts` parses the block the factory + actually returns. Each pin asserts the refusal of `'none'` alongside the + acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now + typed as `StandaloneStackResult['api']`, so it can no longer drift from the + producer without failing `tsc`. +- 95c3703: fix(cli): `os start --port` now wins over `$OS_PORT`, and `start` stops printing an address it is not serving (#12992) + + `os start --port N` printed `N` and then bound something else, whenever + `$OS_PORT` was set. Measured on a real boot before the repair: + + ``` + OS_PORT=41077 os start --port 41078 + banner: Console: http://localhost:41078/_console/ + curl answers on: 41077 + ``` + + Two independent halves, both repaired. + + **The forwarding channel.** `start` wrote the flag into the child's `PORT` and + never cleared the inherited `OS_PORT` beside it. The `serve` child resolves + `readEnvWithDeprecation('OS_PORT', 'PORT')` — `OS_PORT` first — so an explicit + `--port` travelled on the channel its own child ranks **last** and lost to an + environment variable the flag's help text says it overrides. The child's + precedence was correct and is unchanged; the parent now writes the canonical + `OS_PORT` together with its `PORT` alias, so the flag arrives first in the order + the child already reads and every other reader of the child's environment + (app code and libraries that read `process.env.PORT` directly) sees the same + port. No deprecation notice is reachable from either spelling: `OS_PORT` is the + *preferred* name of that pair, and every read site passes `{ silent: true }`. + + Same edit fixes `os start --port 0`, which a falsy guard used to drop entirely — + `0` is a legal port that asks the kernel for a free one (`MIN_PORT = 0`). + Measured before: `os start --port 0` printed `http://localhost:0/_console/` and + bound the inherited `41077`. + + **The lying banner.** `start`'s `Console:` row was a *second* resolution of a + question the child answers for itself, computed with the opposite precedence and + reconciled with nothing. It also asserted a mount it could not know: on the same + boot, `/_console/` answered **404**, because whether a Console is served depends + on the `ConsoleUI` plugin loading in the child. Both facts belong to `serve`, + which already states them together after its `listen()` — the `API:` row always, + the `Console:` row when the plugin actually loaded, both addressed through the + external-base resolver. So `start` no longer prints an address at all, and the + one address it used to print is gone rather than recomputed. + + **User-visible:** `os start` prints one fewer row before the server boots. The + Console URL now comes from the `serve` ready banner, after the bind, and appears + only when a Console is really mounted. Because it is derived from the actual + bind rather than predicted, it is also correct under causes this change does not + touch, including the development auto-shift off a busy port. +- 96940b7: feat(storage): report-only inventory for stranded `sys_file` orphans, plus `os storage orphans` (#10950) + + The tombstone repairs in #10171 (update verb) and #10240 (delete verb) are forward-only: + they changed what the next write does and touched no row already written. Every + attachments-scope file orphaned before them still sits at `status='committed'` with + `deleted_at` NULL and no `sys_attachment` join row — and `sys_file`'s declared lifecycle + nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or + `retention { onlyWhen: { status: 'pending' } }`, so such a row matches **neither**. It is + never a candidate, the reap guard is never asked about it, and its bytes are never + reclaimed. The leak is permanent rather than late. + + This ships the measurement half only, per the maintainer's ruling on #10950: + + - `inventoryStrandedFileOrphans()` — a read-only reconciliation pass that walks + attachments-scope committed `sys_file` rows and reports how many are stranded, their + byte magnitude, and why each excluded row was excluded. `formatStrandedOrphanInventory()` + renders it; those two plus the inventory's result types are what + `@objectstack/service-storage` publishes — the shared ownership predicate stays internal, + since nothing outside the package pulls on it. + - `os storage orphans` — the operator-invoked surface, with `--json` for a machine-readable + payload. There is no `--apply` and no write path, deliberately. + + **It writes nothing, tombstones nothing and deletes nothing.** Authorising the destructive + backfill is a separate decision that these numbers exist to inform; a tombstone written + here would start a 30-day clock ending in an irreversible byte delete. + + The ownership question is not reimplemented. `createSysFileReapGuard`'s "is anything still + holding this file?" test — zero `sys_attachment` join rows **and** empty `ref_*` ownership + columns — is extracted as `findFileHolder()` and called by both the guard and the + inventory, so "the same question, never a weaker one" is a property of the code rather + than a claim in a comment. A file with zero join rows that is `ref_*`-owned (ADR-0104 / + #3459) is a live file and is excluded from the count. + + Behaviour of the reap guard is unchanged — the extraction is a pure refactor, and the + guard's existing pins cover it. Both counts are labelled `attachments` scope: files in the + other scopes are governed by the field-reference seam and are reconciled by + `verifyFileReferences`, which skips attachments-scope files, so the two passes partition + the population rather than overlapping. +- 5091da3: feat(cli): build the cross-producer `sys_secret` reference union — the primitive a safe orphan sweep needs (#12663) + + `sys_secret` has three privileged producers, each holding its handle in a + column of its own: `SettingsService` (a bare `sec_…` in + `sys_setting.value_enc`), the engine's `secret`-typed field channel + (`secret:` on an arbitrary business row) and the datasource credential + binder (`sys_secret:` at a datasource artefact's + `external.credentialsRef`). Nothing enumerated all three, so the only sound + deletion predicate — "attributable AND unreferenced by the COMPLETE union" — + had no union to stand on. `packages/cli/src/utils/secret-reference-union.ts` + is that union, read-only across all three surfaces. + + Why the shipped report-only classifier is not enough, reproduced against real + code in the new test file: `classifySysSecretRows` attributes a row by + `(namespace, key)` membership in the settings manifests' encrypted specifiers, + and that is a **name match, not ownership** — `sys_secret` carries no producer + column and the three producers write those two columns with three different + meanings. A live, engine-owned credential on an object named like a settings + namespace, with a field named like a specifier key, classifies `orphaned` + today. There is no recovery from acting on that: the audit trail records + digests, not handles, so nothing can name the destroyed handle afterwards. + + Completeness is therefore the whole contract, and it is structural rather than + asserted: + + - the family set is closed and the assembler takes a `Record` over it, so + omitting a producer is a type error, not a smaller union; + - a read that did not happen is a declared **gap**, never an empty answer — a + missing driver, a throwing read, an unparseable artefact, or a host that did + not declare its code-defined datasources all make the union + `complete: false`, and `assertSecretReferenceUnionComplete` refuses it with + the ADR-0112 pair `PRECONDITION_REQUIRED` / 428; + - family 2 is enumerated from the metadata registry on every call, because its + holders are every `secret`-typed field on every registered object, + tenant-authored ones included — a newly registered secret field is in the + union with no code change. + + Reads go to the driver through the engine's public `getDriverForObject()`: the + `secret:` ref only exists at that level (the read path masks it + unconditionally), and any scoped read would silently under-report — the + direction that deletes live credentials. + + `patch` rather than `minor`: this adds no surface to the package's entry + barrel and no command or flag. It is an internal primitive whose named reader + is the deletion half of #8103, in this same package; publishing it as an + external API is a separate decision with its own changeset. Read-only by + construction — nothing here writes, deletes or decrypts, and it contains no + deletion command, dry-run or sweep. +- fc8a339: `os migrate plan` / `os migrate apply` now diff the object set the deployment actually serves + + Both commands booted `createStandaloneStack` and nothing else, so on any real deployment they examined a five-table subset — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit`, `sys_metadata_history`, `sys_view_definition` — and reported `0` drift over it. The host `objectstack.config.ts` was never loaded (the standalone stack says so itself) and no platform plugin was composed either: only the DATA subcommands reached `PlatformObjectsPlugin`, through `buildDataMigrationPlugins`. + + That failed in the direction that reads as success. With nothing registered there is no drift, so `plan` printed *"Physical schema is in sync with metadata — nothing to migrate."* — while the driver's own boot-time detector, running with the full registered object set, reported findings on the same database whose message ends `run "os migrate apply"`. Measured against a control plane carrying roughly eighty `sys_*` tables: ten boot-time findings, five tables examined, "in sync". + + `plan` and `apply` now compose what `os serve` composes: the host config's plugins (plus `AppPlugin(config)` when the config carries top-level metadata and brings no app plugin of its own), and `PlatformObjectsPlugin` — the one plugin `serve` injects unconditionally. Both commands compose identically, so the plan an operator reads and the set `apply` reconciles are the same. + + Nothing about what counts as drift changed. A plan that now reports findings it used to hide is the fix working. + + Two behaviours worth knowing: + + - **Host plugins are composed for their DECLARATIONS only** — `init()` runs, `start()` does not. `os migrate plan` is a declared dry run, and host plugins are arbitrary code: composed fully, `SecurityPlugin` alone attempted fourteen inserts into `sys_permission_set` during a plan, from its `start()` bootstrap. The kernel contract puts object declarations in `init()`, which is all a schema command needs. The residue: a plugin that registers its objects in `start()` instead of `init()` stays outside the plan. + - **A project with neither an `objectstack.config.*` nor a compiled artifact is unchanged** — five tables, same output, same `--json` document. There is no deployment there to mirror. + + A host config that exists but fails to load (a missing environment variable is the common case) is reported loudly on stderr and does not fail the command; `os migrate plan --json` then carries `composition.hostConfigLoaded: false`, because the table count alone cannot tell that apart from a deployment that is genuinely small. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [87042b5] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [99ccbb9] +- Updated dependencies [c8be110] +- Updated dependencies [74cee59] +- Updated dependencies [6747718] +- Updated dependencies [340c5e5] +- Updated dependencies [399ecad] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [2a05851] +- Updated dependencies [3690922] +- Updated dependencies [9bc403e] +- Updated dependencies [8bdd955] +- Updated dependencies [32448d4] +- Updated dependencies [54e2d36] +- Updated dependencies [7ef0268] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [5a916c4] +- Updated dependencies [f93df4d] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [eae824e] +- Updated dependencies [178f90c] +- Updated dependencies [f6fa22c] +- Updated dependencies [56d3c7a] +- Updated dependencies [8bb05ea] +- Updated dependencies [8a483b3] +- Updated dependencies [84de7e3] +- Updated dependencies [3bc2e38] +- Updated dependencies [7181101] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [b15d260] +- Updated dependencies [887b97d] +- Updated dependencies [d9aa041] +- Updated dependencies [7899f57] +- Updated dependencies [6274a1a] +- Updated dependencies [1fa05a6] +- Updated dependencies [22c42c9] +- Updated dependencies [5cb62d8] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [df59de0] +- Updated dependencies [daacc10] +- Updated dependencies [cebef55] +- Updated dependencies [6171331] +- Updated dependencies [c6c7fec] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [a58eac3] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [f9ffd01] +- Updated dependencies [376c70f] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [09ae32e] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [34d3011] +- Updated dependencies [aa3f9ba] +- Updated dependencies [9d3c04d] +- Updated dependencies [d29e42f] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [3f42920] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [ebcc34e] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [12e306a] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [0d7b1f3] +- Updated dependencies [8155855] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [78f65ef] +- Updated dependencies [901355c] +- Updated dependencies [107bb4b] +- Updated dependencies [1524927] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [68c5dba] +- Updated dependencies [09b4f4e] +- Updated dependencies [30928a6] +- Updated dependencies [de47336] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [3dafd8c] +- Updated dependencies [0c2334f] +- Updated dependencies [e062370] +- Updated dependencies [66bbb4c] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [365e334] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [20a452e] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e170b0a] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9e72090] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [33b52fe] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [4ceae8a] +- Updated dependencies [48318f7] +- Updated dependencies [b6d9432] +- Updated dependencies [3b5f036] +- Updated dependencies [cf71d73] +- Updated dependencies [311433f] +- Updated dependencies [457ff75] +- Updated dependencies [ece4dad] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [c4ecf0c] +- Updated dependencies [795d14e] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e4902d2] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [017130a] +- Updated dependencies [52954c0] +- Updated dependencies [466b389] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [d81838c] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [431d2fb] +- Updated dependencies [a2ec377] +- Updated dependencies [d9cf78e] +- Updated dependencies [1a47a53] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8649b39] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [31bb2e7] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [6c6157a] +- Updated dependencies [ba8420b] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [db39dfc] +- Updated dependencies [3c0f3ea] +- Updated dependencies [26deb31] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [599515d] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [6a571d3] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [d48929e] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [f4e741b] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [a1c804b] +- Updated dependencies [3519f8d] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [8965398] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [1c66fe4] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [709ce17] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [18b53ac] +- Updated dependencies [bd0c5cc] +- Updated dependencies [e1d773e] +- Updated dependencies [9a884c6] +- Updated dependencies [1cba33f] +- Updated dependencies [da1126a] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a3c4215] +- Updated dependencies [fc8627e] +- Updated dependencies [050d8d8] +- Updated dependencies [e0abc38] +- Updated dependencies [cd13488] +- Updated dependencies [df1c75c] +- Updated dependencies [1e4d2eb] +- Updated dependencies [b853cf3] +- Updated dependencies [4af6c44] +- Updated dependencies [1272f0a] +- Updated dependencies [18b53ac] +- Updated dependencies [3194c91] +- Updated dependencies [65759ba] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [47d9b77] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [3956069] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [9981f31] +- Updated dependencies [a41069b] +- Updated dependencies [d028b37] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [91b1342] +- Updated dependencies [88b9d74] +- Updated dependencies [c94be62] +- Updated dependencies [96940b7] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [f213793] +- Updated dependencies [d173125] +- Updated dependencies [242eb0a] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [2ff01cf] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [1246b4c] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [4d5b4f8] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/client@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/service-settings@17.3.0 + - @objectstack/service-analytics@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/plugin-approvals@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/verify@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-protocol@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/driver-mongodb@17.3.0 + - @objectstack/service-automation@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/console@17.3.0 + - create-objectstack@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/service-messaging@17.3.0 + - @objectstack/lint@17.3.0 + - @objectstack/cloud-connection@17.3.0 + - @objectstack/service-storage@17.3.0 + - @objectstack/plugin-audit@17.3.0 + - @objectstack/plugin-sharing@17.3.0 + - @objectstack/plugin-webhooks@17.3.0 + - @objectstack/service-realtime@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/mcp@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + - @objectstack/plugin-reports@17.3.0 + - @objectstack/plugin-email@17.3.0 + - @objectstack/driver-turso@17.3.0 + - @objectstack/account@17.3.0 + - @objectstack/setup@17.3.0 + - @objectstack/driver-sqlite-wasm@17.3.0 + - @objectstack/formula@17.3.0 + - @objectstack/service-cache@17.3.0 + - @objectstack/service-job@17.3.0 + - @objectstack/service-package@17.3.0 + - @objectstack/service-queue@17.3.0 + - @objectstack/service-sms@17.3.0 + - @objectstack/trigger-api@17.3.0 + - @objectstack/trigger-record-change@17.3.0 + - @objectstack/trigger-schedule@17.3.0 + - @objectstack/plugin-pinyin-search@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 6accd1bdce..a0e047479e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.2.0", + "version": "17.3.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 7199a731e9..b3684d6e42 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,213 @@ # @objectstack/client-react +## 17.3.0 + +### Patch Changes + +- 1fa05a6: fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) + + TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by + building and reading the emitted declarations), so they reach consumers directly in their + editor's hover tooltip — copying one is the intended usage. Two were actively wrong: + + - `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or + `null`), which has no `.map` — copied verbatim, this throws once the query resolves. + Fixed to `data?.records.map(...)`. + - `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not + exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real + `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. + + While in there: every other `@example` on this surface (18 more, across + `client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` + and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each + previously omitted the `import` for the hook or type it demonstrated, and three + `realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose + placeholder, a syntax error once copied. + + **`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now + type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in + this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish + `files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface + moves. Named here only because the fixed-version group requires every package with a + source diff to be covered by a changeset; the actual version bump is a byproduct of the + group moving together, not a claim that spec shipped something new. + + No exported type, function signature, or runtime behaviour changed on any of the three — patch. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [87042b5] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [b15d260] +- Updated dependencies [887b97d] +- Updated dependencies [d9aa041] +- Updated dependencies [7899f57] +- Updated dependencies [6274a1a] +- Updated dependencies [1fa05a6] +- Updated dependencies [22c42c9] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [cf71d73] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [3519f8d] +- Updated dependencies [7986d97] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/client@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index b10d938ef6..af773183e3 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.2.0", + "version": "17.3.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 a4eff4d484..b3f5e01564 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,894 @@ # @objectstack/client +## 17.3.0 + +### Minor Changes + +- 87042b5: feat(client,cli)!: `client.projects.*` becomes `client.environments.*`, the scoped sub-client becomes `ScopedEnvironmentClient`, and the unwrap keys follow the wire (#12866, #12882, ADR-0006 D2) + + + + + **BREAKING** public-API rename on `@objectstack/client`, and a breaking change to + the `--format json` payload of the `os environments` command family. It lands + after the v17.0.0 cut, so the lockstep launch-window convention ships it as + `minor` (`scripts/check-changeset-no-major.mjs`); the version number is not the + migration signal here, this entry is. + + This is the **SDK half** of one coordinated cross-repo rename. The **producer + half** is the cloud control plane, which renames the same field keys on the same + endpoints. Neither half ships alone: shipping the SDK half by itself is + ADR-0006 D3, permanently declined, as is any mapping layer between the two + spellings. + + ## Migration + + **No aliases exist.** The old namespace is gone, not deprecated — there is no + `client.projects` getter, no `res.project ?? res.environment` hedge, and none is + coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule + 「no aliases」 is the standing one). Every call site moves in one edit. + + ### Method namespace + + | before | after | + | --- | --- | + | `client.projects.list(…)` | `client.environments.list(…)` | + | `client.projects.get(id)` | `client.environments.get(id)` | + | `client.projects.create(req)` | `client.environments.create(req)` | + | `client.projects.update(id, patch)` | `client.environments.update(id, patch)` | + | `client.projects.delete(id, opts)` | `client.environments.delete(id, opts)` | + | `client.projects.activate(id)` | `client.environments.activate(id)` | + | `client.projects.rotateCredential(…)` | `client.environments.rotateCredential(…)` | + | `client.projects.updateHostname(…)` | `client.environments.updateHostname(…)` | + | `client.projects.updateVisibility(…)` | `client.environments.updateVisibility(…)` | + | `client.projects.listRevisions(…)` | `client.environments.listRevisions(…)` | + | `client.projects.listBranches(id)` | `client.environments.listBranches(id)` | + | `client.projects.renameBranch(…)` | `client.environments.renameBranch(…)` | + | `client.projects.deleteBranch(…)` | `client.environments.deleteBranch(…)` | + | `client.projects.retryProvisioning(id)` | `client.environments.retryProvisioning(id)` | + | `client.projects.listDrivers()` | `client.environments.listDrivers()` | + | `client.projects.packages.*` | `client.environments.packages.*` | + + The URL paths are unchanged — they were already on the `environments` spelling + (`/api/v1/cloud/environments/…`). Only the method namespace and the response + field keys move. + + ### Response keys + + | before | after | where | + | --- | --- | --- | + | `res.projects` | `res.environments` | `list` (the `total` key is unchanged) | + | `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` | + + The joined blocks on `get` (`database`, `credential`, `membership`, + `organization`) keep their names, as do every `packages.*` key, the + `delete`/`listBranches`/`renameBranch`/`deleteBranch` payloads (already + `environmentId`-keyed), and `listRevisions`. + + ### Two declarations that were false before this change + + Measured 2026-08-28 against the cloud repo's `main`, and corrected here rather + than carried forward under a new spelling: + + - **`create` never answered a `project` key at all.** `POST /api/v1/cloud/environments` + has always answered `{ environment, warnings, durationMs, hostnameAssignment? }`. + The old `{ project: any; database: any }` declaration was not merely + pre-rename, it was wrong against the running control plane — and + `os environments create` read `res.project.id` through it, so the default + `--activate` silently never activated and the table output printed + `undefined`. Both are fixed by this rename. + - **`create` declares no `database` key.** That route does not send one; the key + was declared NON-optional, so `res.database.driver` typechecked and threw. The + method that really answers a `database` block is `get`, which keeps it. + + The keys `create` does send beside `environment` (`warnings`, `durationMs`, + `hostnameAssignment`) are deliberately still undeclared — adding them is new + published surface and a separate decision. + + ### The environment-scoped sub-client (#12882) + + The fourth `project`-spelled surface on the same class, folded in by the same + maintainer ruling. ADR-0006's D1 census named three surfaces and missed this one; + it was an oversight, not a deliberate retention. + + | before | after | + | --- | --- | + | `client.project(id)` | `client.environment(id)` | + | `ScopedProjectClient` (exported class) | `ScopedEnvironmentClient` | + + Same no-alias rule: neither old spelling survives. `client.project(id)` is not a + deprecated method, it is gone, and the exported class is gone under its old name + — a `import { ScopedProjectClient }` fails at the import line, which is the + loudest and most precise channel this change has. + + Nothing about the behaviour moves: the scoped client still prefixes + `/api/v1/environments/:environmentId/...`, still exposes the same `data` / `meta` + / `batch` / `packages` shape, and the thrown guard message becomes + `[ObjectStack] environment(id): environmentId is required`. + + **Deliberately NOT renamed, because each is a different surface needing its own + decision:** `setProjectId` / `getProjectId` on the client — `getProjectId` is a + cross-package protocol contract that `packages/runtime` and + `packages/metadata-protocol` both speak, so it is a coordinated rename, not a + local one — and the REST API config keys `enableProjectScoping` / + `projectResolution`, which are live keys read by `packages/cli/src/commands/serve.ts`. + The docblocks that name them are worded so they stay true. + + Note for whoever compiles the release notes: four other pending changesets in + this release describe methods on `ScopedProjectClient` under its old name + (`client-unannotated-return-erasure`, `client-saveitem-ifmatch-header`, + `client-meta-saveitem-query-options`, `client-precise-sdk-return-types`). They + were accurate when written and are deliberately left alone; this entry is the one + that renames the class. + + ### JSDoc + + The `create` docblock claimed the server delegates to + `ProjectProvisioningService.provisionProject`. That spelling has zero hits in the + control plane (measured 2026-08-28). Both this SDK's docblock and + `os environments create`'s now name the **endpoint** instead, which is the one + identifier an in-repo reader can verify — the class lives in a repo this one + never compiles against, so no gate here could ever have caught the rot. + + ## CLI + + `os environments list | show | create | switch | bind` follow the same rename. + No flag, argument, exit code or command id changes. `--format json` / `--format yaml` + payloads are `formatOutput(res, …)` straight from the control-plane response, so + their top-level keys change with the wire: a script reading `.projects` or + `.project` from those payloads reads `.environments` / `.environment` instead. +- d3bee87: **SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. + + `AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout. + + The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one. + + **Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows. + + **Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed. +- b15d260: fix(client): bind the five in-repo `return res.json()` methods, whose published type was `Promise< any >` (part of #12104) + + **Return-type narrowing on a published SDK (clause-②).** No runtime change: the + value each method resolves to is byte-identical before and after. Only the + DECLARED type moved, off `any` — which is exactly why a runtime test cannot + observe it and the pins for it are type-level. + + > ⓘ Angle brackets are spaced throughout (`Promise< any >`) on purpose — + > GitHub's body sanitizer strips tag-shaped spans, backticks and fenced code + > included. + + Each of the five carried no return annotation and ended `return res.json()`, so + its published type was `Promise< any >`, inherited from `lib.dom`'s + `Response.json(): Promise< any >`. The method text names neither `any` nor + `Promise` nor `unwrapResponse`, which is why the class was invisible to the + greps two earlier censuses used. + + ## What each method declares now + + | method | declared before | declares now | why | + |---|---|---|---| + | `client.analytics.query` | `any` | `BaseResponse & { data: AnalyticsResult }` | dispatcher-served; `deps.success(v)` wraps and `res.json()` strips nothing | + | `client.analytics.meta` | `any` | `AnalyticsMetadataResponse` | same envelope; `data` is the bare `CubeMeta[]` projection | + | `client.analytics.explain` | `any` | `AnalyticsSqlResponse` | same envelope; `data` is `{ sql, params }` | + | `client.automation.trigger` | `any` | `BaseResponse & { data: AutomationResult }` | same envelope, over the payload its sibling `automation.execute` unwraps | + | `client.analytics.queryDataset` | `any` | `AnalyticsResult` | served by `@objectstack/rest`, which ends `res.json(result)` — no envelope | + + `any` is assignable to everything and admits every property read, so a + consumer's code can stop compiling where it previously did not. Concretely: + + - **Reading a payload key off one of the four ENVELOPED results.** + `(await client.analytics.query(q)).rows` compiled and was `undefined` at + runtime; the read the wire always required is `.data.rows`. Same for + `.data` on `meta` / `explain`, and `.data.runId` / `.data.screen` on + `automation.trigger`. + - **Reading `.data` off `queryDataset`**, which is served bare — likewise + `undefined` today, likewise refused now. + - Assigning any of the five results to an unrelated annotation, or forwarding + one to a differently-typed parameter. + + That break is the point: those call sites are already wrong at runtime and the + `any` is what hid it. The compiler is the channel that reaches every affected + consumer, and it is strictly more precise than a release note. + + ## How the shapes were established + + By DRIVING the real producers — a real `AnalyticsService`, a real + `AutomationEngine`, the real `HttpDispatcher` and the real `RestServer`, with + only the socket stood in for — not by reading source and not by asserting + against a mock. Two spec response types that look like the right binding are + NARROWER than the contract their route relays + (`AnalyticsResultResponseSchema.data.fields` and + `TriggerFlowResponseSchema.data`), so those two annotations bind the producer's + contract instead; the near-miss is pinned so a later sweep cannot retarget them. + + ## Scope + + The five families whose producers live in this repo. The 38 better-auth-backed + `auth.*` / `organizations.*` / `oauth.*` methods of the same class are untouched + and keep their erased `any` — they are exactly as permissive as before, and no + consumer loses anything by that. + + No ADR-0087 ledger entry: nothing here is a metadata surface. No Zod schema, no + `packages/spec` declaration and no stored representation changed — the erasure + lived only in a TypeScript return annotation — so `objectstack migrate meta` has + nothing to rewrite and an entry would have no artifact to project into. This is + the disposition #8140, #11925 and #12034 recorded for the same class of SDK + return-type narrowing. +- 887b97d: `meta.saveItem` accepts the query-string options bag its route already reads — + `force`, `packageId`, `mode` — on both clients + + The Phase 3a-destructive gate refuses a metadata save with + `409 DESTRUCTIVE_CHANGE` and ends the message `— re-submit with ?force=true to + proceed.` Both REST `PUT` doors read `?force` off the query string and thread + it, so that sentence is true of an HTTP caller. It was **false of a + first-party SDK caller**: `meta.saveItem(type, name, item)` built a bare path + and a body and sent no query string at all, on either declaration. A caller + who did literally what the refusal prescribed got the identical refusal back, + and the only way to act on it was to abandon `@objectstack/client` for raw + `fetch`. + + Three parameters are newly reachable, and they are exactly the three + `PUT /api/v1/meta/:type/:name` reads: + + - **`force?: boolean`** — `?force=true`, the destructive-change opt-in the 409 + message names. Only the opt-IN is spelled on the wire: `false` and + `undefined` both omit the parameter rather than sending `?force=false`. + That is a hazard avoided, not tidiness — the door refuses a *repeated* + `force` because a repeated value arrives as an array and a non-empty array + is truthy, so an opt-OUT that reached the wire twice would switch the guard + ON. + - **`packageId?: string`** — `?package=`, binding the saved row to a + software package (`sys_metadata.package_id`). Named `packageId` to match the + sibling `getItem` / `getItems` options on the same object. + - **`mode?: 'draft' | 'publish'`** — `?mode=draft`, staging the write as a + pending draft. `'publish'` is the default said out loud and deliberately + sends nothing, since the door acts on `mode=draft` alone. + + **Backward compatible.** The bag is optional and an options-less call builds a + byte-identical URL to before — `''`, not a trailing `?`. Existing + three-argument callers are unaffected, and pins measure that rather than + assuming it. + + Both declarations move together — the unscoped `ObjectStackClient.meta` and + the environment-scoped `ScopedProjectClient.meta` — sharing ONE exported + `SaveMetaItemOptions` type and ONE query builder rather than a literal copied + into each. They are the same method on two clients reaching one pair of routes + (the scoped mount is the same route registration replayed under + `/environments/:environmentId`, so it reads the same three parameters), and + every divergence measured between these twins so far has been closed as a + defect. A bag spelled twice is the next one waiting to be introduced. + + The branch was selected by measurement, not preference: the SDK is the real + metadata-write path for both surfaces the ruling named. The CLI's `os meta + register` goes through `client.meta.saveItem` and the CLI has no raw-HTTP + metadata-save path at all; Studio reaches it from 21 production call sites + across `@object-ui/app-shell`, `plugin-designer`, `data-objectstack` and the + console app — including the object and field designers, where dropping a field + and saving is precisely what raises the destructive 409. +- d9aa041: fix(client): `packages.install` / `enable` / `disable` declare the bare `InstalledPackage` row the only serving surface actually sends (#12034) + + **Accept-set narrowing on a published SDK (clause-②), and a false declaration + deleted.** No runtime change: the value each method resolves to is + byte-identical before and after. What moved is the DECLARED type — and unlike + its #11925 siblings this one was not merely erased, it was **wrong**. + + FROM → TO, all three methods: + + | method | declared before | declares now | + |---|---|---| + | `client.packages.install(manifest, opts?)` | `{ package: any; message?: string }` | `InstalledPackage` | + | `client.packages.enable(id)` | `{ package: any; message?: string }` | `InstalledPackage` | + | `client.packages.disable(id)` | `{ package: any; message?: string }` | `InstalledPackage` | + + No surface has ever emitted `{ package, message }` for these three. Each is + served by exactly one implementation — `runtime`'s `/packages` dispatcher domain + — and it answers `success(pkg)`, i.e. `{ success: true, data: }`, which + `unwrapResponse` strips to the bare row. `@objectstack/rest`'s registrar mounts + no twin for any of them (it mounts only `POST /packages/publish`, + `GET /packages`, `GET /packages/:id`, `DELETE /packages/:id`), so there was + never a question of which surface to match. + + **Migration — read the row, not `.package`.** Because the member was `any`, + the false read compiled and silently produced `undefined` at runtime: + + ```ts + // BEFORE — compiled, and `pkg` was `undefined` at runtime + const pkg = (await client.packages.enable(id)).package; + const note = (await client.packages.install(manifest)).message; + + // AFTER — the response IS the row + const pkg = await client.packages.enable(id); + pkg.enabled; // the state the verb just changed + pkg.manifest.version; + ``` + + A consumer stops compiling where it reads `.package` or `.message` off these + three results, or assigns the result somewhere `InstalledPackage` does not fit. + That break is the point: those call sites are already broken at runtime today + and the `any` is what hid it. The compiler is the channel that reaches every + affected consumer, and it is strictly more precise than a release note. + + **What deliberately did NOT change: `client.packages.get`.** It keeps + `{ package: any }`. That route is a real fork — the dispatcher answers the bare + row while the REST registrar answers `{ package: { ...row, source } }`, both + measured by driving each registrar — so no declaration is true on both surfaces. + Binding either member would harden a falsehood, which is the defect this change + removes for its neighbours. Making `get` bindable requires converging the two + PRODUCERS, a wire-behaviour change to two mounted surfaces; the measured + convergence cost is recorded on #12034 for that ruling. + + No ADR-0087 ledger entry: nothing here is a metadata surface. No Zod schema, no + `packages/spec` declaration and no stored representation changed — the phantom + members existed only in a TypeScript return annotation — so `objectstack migrate + meta` has nothing to rewrite. This is the disposition #11925 and #8140 recorded + for the same class of SDK return-type narrowing. +- 7899f57: Bind the SDK's erased return types to the `@objectstack/spec` contracts the package already depends on + + **This is a NARROWING of published return types.** 41 methods that resolved to `any` (or to an + envelope carrying an `any[]`) now resolve to the contract type the route actually answers, and 12 + fixed-shape `automation.*` methods gain a constrained generic in place of ``. Nothing + changes at runtime — no request, response, unwrapping or error path is touched — but code that + compiles today against these methods can stop compiling. `any` is assignable to everything and + admits every property read, so the previous declaration accepted assignments, property reads and + parameter forwarding that a precise type refuses. + + What a consumer could stop compiling against, per family: + + - **`automation`** — `get`/`getFlow` are `FlowParsed`; `runs.get`/`getRun` are `ExecutionLog`; + `runs.list`/`listRuns` are `{ runs: ExecutionLog[]; hasMore: boolean }`; `execute` and `resume` + are `AutomationResult`; `getScreen` is `{ runId: string; screen: ScreenSpec }`; `listActions` and + `listConnectors` carry `ActionDescriptor[]` / `ConnectorDescriptor[]` instead of `any[]`. ⚠️ The + biggest practical break is `AutomationResult.screen`, `.runId`, `.status` and `.summary` being + **optional**: a completed run carries no screen, so `result.screen.nodeId` must become + `result.screen?.nodeId`. The six flat aliases and their `ScopedProjectClient` mirrors move + together. ⚠️ `` became `` on those twelve: an explicit type argument + still works when it narrows the platform shape (`getFlow`), + but one naming an unrelated type is now refused — including where TypeScript used to infer it + from the assignment's own annotation. + - **`approvals`** — `recall` / `revise` / `resubmit` are `ApprovalRecallResult` / + `ApprovalSendBackResult` / `ApprovalResubmitResult`; `remind` is + `{ request: ApprovalRequestRow; notified: number }`; `requestInfo` and `comment` are + `{ request: ApprovalRequestRow }`. These join `reassign` and `listActions`, which were already + typed this way beside them. + - **`shares` / `shareLinks`** — `shares.list` is `RecordShare[]`, `shares.grant` is `RecordShare`; + `shares.rules.list` / `save` / `get` are `SharingRuleRow`(`[]`) and `rules.evaluate` is + `SharingRuleEvaluationResult`; `shareLinks.create` / `list` are `ShareLink`(`[]`). + - **`reports`** — `list` / `save` / `get` are `SavedReport`(`[]`), `run` is `ReportRunResult`, + `schedule` / `listSchedules` are `ReportSchedule`(`[]`). + - **`security`** — `describeDelegableScope` is `DelegableScope`; `explain` is `ExplainDecision` (the + `z.input` form `ISecurityService.explain` declares and the route relays verbatim — **not** the + post-parse `ExplainDecisionParsed`, since no parse runs on that path); the three + `suggestedBindings` methods carry their `{ suggestion, … }` / `{ suggestions, synced }` envelopes. + The suggestion ROW stays `Record` by contract, but `bindingCreated` and + `synced.{created,confirmedObserved,pruned}` stop being erased. + - **`email` / `datasources.external`** — `email.send` is `SendEmailResult` (branch on `status`). + ⚠️ The four federation methods are **envelope-wrapped** and the obvious binding is the wrong one: + `listTables` answers `{ tables: RemoteTable[] }`, not `RemoteTable[]`; likewise + `{ draft: ObjectDraft }`, `{ object: ImportObjectResult }`, `{ catalog: ExternalCatalog }`. + `validate` is a bare `SchemaValidationReport`. + - **`ScopedProjectClient.packages.list`** — `{ packages: InstalledPackage[]; total: number }`. + + Four methods deliberately keep `Promise` and say so in their docblocks: `automation.create` / + `automation.update` echo an unvalidated request body, and `search` / `data.clone` answer shapes + declared inline in the implementation rather than in `@objectstack/spec`. Those are missing + *contracts*, not missing annotations, and authoring them belongs to the spec package. The + caller-supplied generics on `data.*` and `actions.*` are unchanged — there the payload really is + the caller's. +- 6274a1a: feat(client): `meta.saveItem` can send the `If-Match` OCC header it already told callers to send (#11713) + + `saveItem`'s own docstring has always named the ADR-0008 optimistic-concurrency + protocol: the resolved `version` is the token, echo it back as the `If-Match` + request header on the next write to the same item, and a concurrent edit is + reported as `409 METADATA_CONFLICT` instead of silently overwriting. Both REST + `PUT` doors read `if-match` and thread it as `parentVersion`, so that sentence + was true of a raw-HTTP caller. It was **false** of a first-party SDK caller: + neither `saveItem` declaration accepted a header, an `ifMatch`, or anything + that became one — so an SDK caller who did exactly what the docstring said had + nowhere to put the token and their concurrent edit overwrote anyway, answered + `200`. Declared, not enforced, with no signal at the call site. + + **What is new:** `ifMatch?: string` joins the `SaveMetaItemOptions` bag that + `#11391` landed, on **both** `saveItem` declarations — the unscoped + `ObjectStackClient.meta` and the environment-scoped + `ScopedProjectClient.meta` — wired to the `If-Match` request header through a + single shared builder, the same way the three query parameters go through one + shared query builder. The twins cannot drift. + + ```ts + const saved = await client.meta.saveItem('object', 'customer', doc); + // …later, guarded against a concurrent edit: + await client.meta.saveItem('object', 'customer', next, { ifMatch: saved.version }); + // a stale token now answers 409 METADATA_CONFLICT instead of overwriting + ``` + + Purely additive and opt-in. Only a non-empty token reaches the wire: + `undefined` and `''` both omit the header entirely — the `init` handed to + `fetch` carries no `headers` key at all — so every existing call is + byte-identical and last-write-wins remains the default, on the wire and on the + door. Unlike the bag's `mode`, `ifMatch` reaches **both** save doors: the + compound-name twin `PUT /meta/:type/:section/:name` reads `if-match` and strips + ETag-style quotes exactly as the single-segment door does. + + Aligned deliberately with the other first-party client: the same member name, + the same header, and the same truthy guard as `MetadataClient.save` in + `@object-ui/data-objectstack` — two first-party clients, one behaviour. +- 22c42c9: fix(client): bind the three verifiable methods of the unannotated return-type erasure population to their spec contracts (#11925) + + **Return-type narrowing on a published SDK.** No runtime change — the value each + method resolves to is byte-identical before and after. Only the DECLARED type + moved, off `any`, which is precisely why a runtime test cannot observe it and + the pins for it are type-level. + + `any` is assignable to everything and admits every property read, so for each + method below a consumer's code could stop compiling where it previously did + not: assigning the result to an unrelated annotation, reading a property the + bound type does not declare, or forwarding the value to a differently-typed + parameter. + + ## What changed, per family + + **`client.packages.list` → `{ packages: InstalledPackage[]; total: number }`** + (was `{ packages: any[]; total: number }`). A consumer stops compiling if it + reads any key off a row that `InstalledPackage` does not declare. Note in + particular `source` (`'database' | 'registry' | 'both'`): the REST surface + spreads it onto each row, the dispatcher surface does not, and it is therefore + deliberately NOT declared — code reading `pkg.source` off this result compiles + today and will not after. `total` and the array envelope are unchanged. + + **`client.packages.update` → `InstalledPackage`** (was `any`). This method + declared no envelope before, so nothing about the shape claim changed; a + consumer stops compiling if it reads an undeclared key off the returned row, or + assigns the result somewhere `InstalledPackage` does not fit. + + **`ScopedProjectClient.packages.get` → `{ package: InstalledPackage }`** (was + `{ package: any }`). The `{ package }` envelope is unchanged; only the member + narrowed. A consumer stops compiling if it reads a key off `.package` that + `InstalledPackage` does not declare — again including `source`, which this + route does send and which stays undeclared for consistency with its already + bound `list` sibling. + + ## What deliberately did NOT change + + The other 36 methods in the measured population keep their erased `any`, each + with a docblock stating why and pointing at the issue that carries it: + `meta.*` history/diagnostics (9) and eight `packages.*` routes have no published + response contract to bind to (#12038); `client.packages.get` has two mounted + surfaces that emit different envelopes and `install`/`enable`/`disable` declare + an envelope no surface emits (#12034); the 15 cloud `projects.*` methods call a + control plane that speaks snake_case while the `@objectstack/spec/cloud` rows are + camelCase, so binding to them would compile and be false (#12036). + + No consumer loses anything by those staying `any` — they are exactly as + permissive as before. +- daae7aa: Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) + + Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real + contracts. Their shapes were always stable and server-produced — they were declared inline on the + implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no + spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` + exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: + no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled + as part of this change). + + **`@objectstack/spec` (additive):** + + - `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, + `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: + `{ query, hits, totalObjects, totalHits, truncated }` with hits of + `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / + `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` + (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap + #8140 left a compile-time guard against, and the guard stands unchanged. + - `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body + of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s + structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). + No `droppedFields` member — unlike `createData`, the clone producer emits none. + + **`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is + now `Promise` and `data.clone` is `clone(…): Promise>` + (a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing + changes at runtime — no request, response, unwrapping or error path is touched — but code that + compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated + types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now + refused at compile time, which is the trap the erasure used to hide. + + The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to + the decision inbox with a consumer-survey reading per the same ruling. +- cf71d73: feat(client): `meta.deleteItem` can pin a reset (`If-Match`) and discard only the pending draft (`?state=draft`) (#12181) + + Accept-set widening on a published SDK surface: both `deleteItem` declarations + — the unscoped `ObjectStackClient.meta` and the environment-scoped + `ScopedEnvironmentClient.meta` twin — take a third, optional + `DeleteMetaItemOptions` argument. Existing calls are unchanged: with the bag + omitted, the request is byte-identical to what this method has always sent + (no header key, no query string). + + FROM → TO: + + ```ts + // FROM — the only reset a first-party SDK caller could express + await client.meta.deleteItem('view', 'shared_grid'); + + // TO — pin the reset against the version you read (ADR-0008 OCC) + const saved = await client.meta.saveItem('view', 'shared_grid', spec); + await client.meta.deleteItem('view', 'shared_grid', { ifMatch: saved.version }); + // concurrent edit → 409 metadata_conflict, instead of silently resetting it + + // TO — discard ONLY the pending draft; the published overlay keeps serving + await client.meta.deleteItem('view', 'shared_grid', { state: 'draft' }); + ``` + + Why it matters: `DELETE /meta/:type/:name` has always read the `If-Match` + header and threaded it as `parentVersion` (the spec's own + `DeleteMetaItemRequest.parentVersion` describes the pin), and the sibling + first-party client `@object-ui/data-objectstack` `MetadataClient.reset` + already sent it — but this client had no argument for it, so every SDK reset + was last-write-wins on the one verb whose whole job is destroying an overlay + row. `state: 'draft'` reaches the NARROWER reset; without it the only + reachable reset was the full one, which drops the published overlay too. + + ⛔ `?dropStorage=true` is deliberately NOT part of this bag. It is the one + carrier the reset door reads that ADDS destructive reach — it drops the + object's physical table — no caller was measured needing it from this client, + and the door's repeated-parameter refusal exists because of that + destructiveness. A caller that needs it is a separate, separately reviewable + widening. + + `state: 'active'` is the explicit spelling of the default and deliberately + sends nothing; an empty `ifMatch` (`''`) omits the header rather than pinning + against the empty string. +- 3519f8d: fix(client,rest): state `SaveReportInput`'s requirements at the `reports.save` door (#11926) + + **BREAKING** accept-set narrowing on `POST /api/v1/reports` and on the + `client.reports.save` parameter type, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + `IReportService.saveReport` takes a `SaveReportInput`, on which `name`, `object` + and `query` are all required. Nothing on the path said so. The SDK method + declared its parameter `any`, and the route forwarded `req.body ?? {}` straight + through, so the requirement held only as far as each reports implementation + chose to re-derive it privately — the bundled `@objectstack/plugin-reports` does + re-derive all three, but a third-party implementation need not, and a caller + could not tell which one it was talking to. This is the ADR-0078 + declared-but-unenforced shape arriving at an authoring surface: the producer + accepted off-spec input and handed it to a service that requires more. + + Both halves now state the contract: + + - **`client.reports.save(report)`** takes `SaveReportInput` instead of `any`. + Omitting `query` (or `name`, or `object`) is now a compile error at the call + site rather than a surprise from whichever implementation is mounted. The SDK + remains a transport and adds no runtime validation — it is not a second + validator. + - **`POST /api/v1/reports`** refuses a body missing any of the three required + keys, and a `query` that is not a `ReportQuery` envelope (a scalar or an + array), with `400` / `VALIDATION_FAILED` — the same envelope the route + already produced for a service-raised validation error (ADR-0112). A + JavaScript or `curl` caller that never sees the TypeScript type is refused + too. The refusal is ordered **after** the existing `501` for an unmounted + reports service: "no reports service on this deployment" is a deployment fact + and outranks anything about the body. An empty `query: {}` stays legal — + every field on `ReportQuery` is optional — and is pinned as such. + + **Migration.** A caller that omitted `query` was already relying on + implementation-specific behaviour; supply the `ReportQuery` envelope the report + should run (`{}` for "no filters"). Callers already sending a complete + definition are unaffected, and the bundled reports implementation already + refused all three omissions, so no deployment running it changes behaviour — + only the layer that produces the refusal moves, from the service to the door. + + +- 7986d97: Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + + Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. + Stage 1 declared the item-name grammar and refuses every slash-bearing name at + the publish door, so the routes removed here addressed only names that can no + longer be created. + + **BREAKING — three public REST routes stop answering:** + + | stops answering | use instead | + | :-- | :-- | + | `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | + | `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | + | `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + + Each retired route folded its `:section` and `:name` segments back into one + slash-bearing key (`views/all_leads`) that the protocol layer then treated as a + single opaque string — the section half was never stored, filtered or + enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + + The `@objectstack/runtime` dispatcher stops folding in the same way: its + `/meta` handler requires exactly two path segments for an item and three for + `…/published`, instead of re-joining every trailing segment. A `/meta` path + that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than + falling through to the adapter's anonymous 404. + + **FROM → TO for callers.** Address every item through the single-segment route + and percent-encode the name: + + ``` + GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads + ``` + + `@objectstack/client` now calls `encodeURIComponent` on every `/meta` item + address, so SDK callers need no change: the SDK already sends the new spelling. + Encoding is a **no-op** for every name the item-name grammar admits (lowercase + snake_case segments, optionally dot-qualified), so the bytes on the wire are + unchanged for every name that can be written today. + + A pre-grammar **residue** row whose stored name contains a slash remains + readable, writable and deletable: `%2F` matches the single-segment pattern and + the parameter is decoded back to the stored spelling before the handler runs. + Nothing that could be stored has become unaddressable. + + Two SDK doc comments that promised "compound names pass through unencoded" + (`meta.getPublished`, `meta.publishItem`) are corrected, and the + `SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored + at the compound door and published live — is closed at the source: there is one + door, and it reads every member of the options bag. + + +- dc75ba8: feat(spec,client): bind published response contracts for the 17 unbound client-SDK methods; retire the false `PackageRollbackResponseSchema` (#12038, ruling 1C · 2C · 3A · 4A · 5A) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` `api/PackageRollbackResponse` + plus the D3 semantic entry `package-rollback-response-retired` — where + `os migrate meta` users will look). + + FROM → TO: + + - `PackageRollbackResponseSchema` / `PackageRollbackResponse` / + `PackageRollbackResponseParsed` → `RollbackToPackageCommitResponseSchema` / + `RollbackToPackageCommitResponse` (`@objectstack/spec/api`). The retired + schema declared a VERSION rollback (`{ success, restoredVersion?, + message? }`) while the live `POST /packages/:id/rollback` route posts + `{ commitId }` and answers the ADR-0067 COMMIT rollback — + `{ success, revertedCommits: string[], failed: [{ commitId, error }] }`. + Read `revertedCommits` / `failed`; there is no `restoredVersion`. + - `PackageApiContracts.rollbackPackage` → *(removed)* — it bound the + wrong-operation schema to the exact live path. No route registration or + SDK generation ever consumed it (zero consumers measured across + objectstack, objectui and cloud; only its own unit test and the #11925 + compile-time guard, both updated in this PR). + + One-line fix: replace any import of `PackageRollbackResponse(Schema)` with + `RollbackToPackageCommitResponse(Schema)` and read `revertedCommits` / + `failed` instead of `restoredVersion`. `PackageRollbackRequestSchema` stays + published (ruled out of the retirement), bound to no route. + + The rest of the change is additive — the recorded five-part maintainer + ruling (2026-08-27) for the 17 client-SDK methods that had no published + response contract: + + - **12 describe-only transcriptions** into `@objectstack/spec/api`, each + from the return type its producer already declares inline (no wire byte + changes): `ListDraftsResponseSchema`, `GetMetaDiagnosticsResponseSchema`, + `FindReferencesToMetaResponseSchema`, `RollbackMetaItemResponseSchema`, + `DiffMetaItemResponseSchema`, `ResolvedBookSchema` (authored beside its + interfaces in `system/book.zod.ts`), `DiscardPackageDraftsResponseSchema`, + `ListPackageCommitsResponseSchema` (the `{ commits }` wrapper declared as + the handler's own), `RevertPackageCommitResponseSchema`, + `RollbackToPackageCommitResponseSchema`, + `ReassignOrphanedMetadataResponseSchema`, `DuplicatePackageResponseSchema`. + - **Ruling 1C**: `GetPublishedMetaItemResponseSchema` is deliberately opaque + (`z.unknown()`) — the route answers an arbitrary metadata item body, never + a union frozen against the type registry. + - **Ruling 2C**: `meta.migrateStored` stays UNBOUND, documented at its two + ledger rows and in the SDK — `StoredMigrationReport` lives in + `@objectstack/metadata-protocol`, and a second declaration would drift. + - **Ruling 4A**: `PackageExportManifestSchema` pins the four fixed keys + (`id`, `name`, `version`, `label?`) and stays honestly open for the + registry-derived plural keys. + - **Ruling 5A**: `PackagePublishResultSchema` and the `ResolvedBook` family + are re-exported into `@objectstack/spec/api` (the namespace the + route-ledger resolver searches) — never a second copy. + - The 18 boundable route-ledger rows in `@objectstack/runtime` and + `@objectstack/rest` now name their `responseSchema`, each stating which + surface's envelope it describes; every named schema carries conformance + coverage (the #3877 rule). + - The client SDK binds 16 of the 17 methods to the published payload types, + replaces four invented test mocks with producer-true shapes, and pins the + `unwrapResponse` mis-unwrap hazard so no bound payload can declare both a + boolean `success` and a `data` key. + +### Patch Changes + +- 1fa05a6: fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) + + TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by + building and reading the emitted declarations), so they reach consumers directly in their + editor's hover tooltip — copying one is the intended usage. Two were actively wrong: + + - `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or + `null`), which has no `.map` — copied verbatim, this throws once the query resolves. + Fixed to `data?.records.map(...)`. + - `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not + exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real + `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. + + While in there: every other `@example` on this surface (18 more, across + `client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` + and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each + previously omitted the `import` for the hook or type it demonstrated, and three + `realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose + placeholder, a syntax error once copied. + + **`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now + type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in + this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish + `files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface + moves. Named here only because the fixed-version group requires every package with a + source diff to be covered by a changeset; the actual version bump is a byproduct of the + group moving together, not a claim that spec shipped something new. + + No exported type, function signature, or runtime behaviour changed on any of the three — patch. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 0aa6222e87..7495670cd9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.2.0", + "version": "17.3.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 b91759172a..38abe84876 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,248 @@ # @objectstack/cloud-connection +## 17.3.0 + +### Minor Changes + +- 709ce17: **Security (p0, upstream half):** `GET /api/v1/runtime/config` now serves the Console's client error-reporting **sink** — the DSN itself, plus the closed set of knobs that travel with it — so a self-hosting operator configures telemetry on the server, in one place, with no frontend rebuild (#12681, upstream half of `cloud#1508`). + + ```json + { + "telemetry": { + "errorReporting": { + "dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42", + "sendDefaultPii": false, + "environment": "production", + "tracesSampleRate": 0.1, + "replaysOnErrorSampleRate": 0 + } + } + } + ``` + + An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. The first fix (#10805) served a runtime *permission* and left the *source* where it was — a build-time `VITE_SENTRY_DSN` inlined into the published bundle. That closed the leak and opened a different hole, which the maintainer named on 2026-08-27: + + > 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」 + + ObjectStack's users consume a **prebuilt** Console. They cannot set a build-time key, so under the two-key gate a self-hosting operator could not enable client error reporting at all: the permission was reachable and the source was not. + + **The DSN's presence IS the grant.** There is no second boolean, and this is not shorthand — it removes the failure mode the two-key shape had. With a permission and a source configured in different places, "permission on, no DSN" and "DSN in, permission off" are two silent dead states that look identical from the browser. One knob cannot disagree with itself. + + The fail-closed direction survives the collapse for free, and more robustly than the boolean managed: the grant is now "a non-empty DSN reached me", so an older runtime, a third-party host, a 404, a network error, a malformed body and a payload that has not arrived yet all carry no DSN and therefore deny. A boolean needed `=== true` plus a written argument about why `disabled: true` would have been vacuous; absence of a *source* is not a value that can be misread. + + **Everything that must travel with the DSN travels with it.** `sendDefaultPii`, `environment`, `tracesSampleRate` and `replaysOnErrorSampleRate` were build-time `VITE_SENTRY_*` variables, which a prebuilt-console consumer could set none of — including the one deciding whether IP and User-Agent leave the network. This is not new surface; it is the same surface moved to the side that can operate it. One knob deliberately did **not** move: a release identifies *which bundle* produced a stack trace and must match that build's uploaded source maps, so `VITE_SENTRY_RELEASE` stays build-time in objectui and is the only `VITE_SENTRY_*` knob that does. + + **Malformed is refused at mount, never coerced**, and every refusal lands on the safer value. A DSN that is not an `https://PUBLIC_KEY@HOST/PROJECT_ID` URL is refused and the whole block withheld — there is no safe default for a source. A DSN carrying a **secret** after the public key is refused for a different reason: this payload is read by every browser that loads the Console, so a legacy secret-bearing DSN would publish that secret to every visitor while looking entirely ordinary. A bad sample rate falls back to its documented default instead, because silencing error reporting over a typo in a volume knob would be strictness pointed away from the hazard. Quoted values are key-redacted: boot logs travel further than the configuration they quote. + + **A runtime that declared its control plane off serves no sink.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the DSN and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration. + + **Absence is denial, and the reading ships with the contract.** `readClientErrorReporting(payload)` is the canonical fail-closed reader, returning the sink or `null`; a failed fetch is spelled by passing `undefined`, so the error path and the absent path reach the same answer through the same function. It is exported rather than left to consumers because "no DSN means do not send" is a claim about *their* code. + + ### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled + + The #10805 permission boolean is removed in this same change — no dual-spelling window. It was added days ago, is **unreleased** (it appears in no published `CHANGELOG.md`), and no deployment consumes it; its pending changeset is superseded by this one rather than shipping a feature and its removal in the same release notes. + + | FROM | TO | + |:--|:--| + | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` | + | `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` | + | `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` | + | `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` | + | `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) | + + One-line fix for an operator: replace the `..._ENABLED=true` line with a `..._DSN=` line carrying your DSN. One-line fix for a consumer: `if (readClientErrorReporting(payload)) …` in place of `if (buildTimeDsn && isClientErrorReportingAllowed(payload)) …` — the build-time conjunct is gone, because the server now supplies the source. + + **Landing order is safe in both directions.** An old client meeting this server reads an absent `allowClientErrorReporting` and denies; a new client meeting an old server reads an absent DSN and stays off. Neither half can turn reporting on by itself, so the two repos' PRs can land in any order. + + + +### Patch Changes + +- 12e306a: The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [7ef0268] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c68c670] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [7986d97] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index f7cceac7ec..48355b6325 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.2.0", + "version": "17.3.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 aebd0a14aa..8eb3fca372 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/connector-mcp +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index e5a3337bca..0aa6fada47 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.2.0", + "version": "17.3.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 101185464f..d5271c9a53 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/connector-openapi +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 1a6ad04609..2fb927e1a7 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.2.0", + "version": "17.3.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 64c592d12d..126a12a313 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/connector-rest +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 4c5153f3f6..579e448dfc 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.2.0", + "version": "17.3.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 e24c980b48..770cc6ab1a 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/connector-slack +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 909f052546..476fa3b7eb 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.2.0", + "version": "17.3.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 1bc3434c55..05add619bf 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,309 @@ # @objectstack/console +## 17.3.0 + +### Minor Changes + +- df59de0: Console (objectui) refreshed to `9602dc820450`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 188 releasing of 279 changesets added across 359 non-merge commits; omitted: 91 release-nothing changesets, 82 commits carrying no changeset (they ship no package code). + + - **minor** — Setup › **Packaged automation** gains a packaged **actions** section beside its flows one — the surface half of ADR-0126 §8 item 2 (objectui#6412; the engine, ledger and both disp… (objectui `9602dc820`) + - **minor** — Setup gains a **Packaged automation** page — the operational surface for the flows an installed package ships (ADR-0126 §7.4, objectui#6301). Reached the way every other framework… (objectui `b362c1b47`) + - **minor** — `TableColumn.type` now has ONE canonical value set across all three ends that disagreed (objectui#5853, maintainer ruling 2026-08-25, Option B: the 8-literal interface union is ca… (objectui `fc62bb490`) + - **minor** — `TimelineSchema` now declares the presentational keys the timeline renderer actually reads (objectui#6170, maintainer ruling 2026-08-25 — the same family rule adopted on objectui#… (objectui `e4559d1e0`) + - **minor** — `navigateOnSuccess` is relative-only, escapes the interpolated id, and is deprecated in favour of `submitBehavior` (objectui `636b2363f`) + - **minor** — `objectui check` recognises a schema by validating it, and reports broken ObjectUI files instead of filing them as foreign ones. (objectui `7dc08a3e7`) + - **minor** — The Field Designer no longer offers a formula-expression textarea, and no designer write path emits a `formula` key (objectui#6043). (objectui `864154e77`) + - **minor** — `ComponentMeta` at the registry is now DERIVED from the one declaration in `@object-ui/types` instead of restating it, and `tags` / `description` reach the registration surface (o… (objectui `44d075ba0`) + - **minor** — `StackSchema` now SHIPS the members it declares (objectui#6151). Its emitted declaration carried one property — `type` — where it was meant to carry twenty-five. (objectui `db3a6008e`) + - **minor** — ⚠️ **Behaviour change: `current_user` predicates that have been doing nothing on the console form routes and in the wizard's submit gate now TAKE EFFECT.** Read this before upgrad… (objectui `5173a5ece`) + - **minor** — **BREAKING** — Object-level metadata payloads no longer emit the three keys `ObjectSchema` refuses by name — **group**, **sortOrder** and **relationships** (objectui#6223). (objectui `d18a0d3af`) + - **minor** — `radio-group` now renders the `orientation` its own type has always declared (objectui#6158). (objectui `6fd2cf7a0`) + - **minor** — ⚠️ **Behaviour change: an authored `FormSection.visibleWhen` that has been doing nothing will now START HIDING SECTIONS.** Read this before upgrading if any of your metadata autho… (objectui `971d38748`) + - **minor** — **Breaking (shipped as `minor` per AGENTS.md §版本号策略).** The published `toSortItems` export now reads its sort direction from **`order`** only. The retired spelling is **`direction… (objectui `c5fbe0b99`) + - **minor** — **View configuration is explicitly org-wide, and its write path is now gated (objectstack#7494's ruling, maintainer 2026-08-12).** The `sort` / `hiddenFields` / `columnState` / `r… (objectui `41b7ce3ce`) + - **minor** — Retire `CRUDSchema` and the `type: 'crud'` node spelling (objectui#5373, maintainer ruling 2026-08-20, route 2) under ADR-0049 enforce-or-remove. (objectui `105f3c55c`) + - **minor** — The metadata designer states its package on the publish step, not only on the save (#5420) (objectui `b470e9150`) + - **minor** — Grid headers offer a sort click only on columns the PLATFORM says it will order by (objectui#5729 — the consumer leg of objectstack#10235, maintainer ruling A, 2026-08-23: the pla… (objectui `490d9a93a`) + - **minor** — **Retired the designer-surface dashboard `aria` pair — `DashboardConfig.aria` and `DashboardConfigSchema.aria`** (objectui#5852). (objectui `e7957ab87`) + - **minor** — The metadata designer's View **column list** now reads a column's identity — row label and bound field name — in the ObjectStack canonical spelling only: `field` and `label`. The… (objectui `f471b4f73`) + - **minor** — An auto-derived related list now orders its rows by the CHILD object's default list view `sort`, instead of falling to the server's primary-key order (objectui#5795). A task versi… (objectui `6d63cd029`) + - **minor** — **BREAKING** — `ObjectTrigger` and `ObjectRelationship` are removed from `@object-ui/types` — two hand-written interfaces orphaned by the `ObjectSchemaMetadata` derivation (objectui#5859, triage… (objectui `41df89320`) + - **minor** — `ComponentMeta` is now declared once and re-exported, and `PluginComponentMeta` is deprecated in favour of `ComponentMeta` (objectui#5893). (objectui `6d1c15536`) + - **minor** — `MetadataClient.layered()` validates the ADR-0010 protection envelope against the producer's own schema at the boundary, instead of casting ten wire fields through unchecked (obje… (objectui `94e2fa725`) + - **minor** — `ObjectGanttSchema` declares the ten gantt keys `ObjectGantt` actually reads (objectui#5903, triage 2026-08-24). Every one is a real, working, documented feature — `readOnly`, `mo… (objectui `00f3eb5b9`) + - **minor** — Route a `data-table`'s `emptyAction` node through `SchemaRenderer`, so a `visibleWhen` authored on it is actually evaluated (objectui#5926 gap 1). (objectui `f8cdbf2cc`) + - **minor** — **Behaviour change.** Form section and field `visibleWhen` predicates that silently failed OPEN now actually evaluate — and a rule that resolves FALSE now hides the field (objectu… (objectui `aefe42898`) + - **minor** — **`@object-ui/types/zod` now accepts seven spellings its own TypeScript declarations already declared** (objectui `69a2163d9`) + - **minor** — **Breaking (shipped as `minor` per AGENTS.md §版本号策略).** `ObjectViewProps.views[].sort` now spells its direction key **`order`**. The retired spelling is **`direction`** — named he… (objectui `53dc89db8`) + - **minor** — **BREAKING (authoring): `ui:icon` names its glyph with `icon`, not `name`** (objectui `d2fb6efb4`) + - **minor** — Close the dashboard widget `type` vocabulary, and admit `metric-card` as objectui's own component extension. (objectui `f7e34ca0b`) + - **minor** — Console telemetry can now be hard-disabled on an already-built artifact (objectui `26fbe09b0`) + - **minor** — **BREAKING** — Retire the `ActionCondition` `{ expression, then, else }` branch shape from `ActionSchema.condition` (objectui#3917, maintainer ruling 2026-08-09 route B, enforce-or-remove). (objectui `6a9158602`) + - **minor** — `ui:icon`: an unresolvable glyph now renders a visible placeholder instead of nothing (objectui `7cd398737`) + - **minor** — Classify `/analytics/query` failures by their ADR-0112 `code` rather than their HTTP status, so a chart is no longer answered from a different code path behind the wrong explanati… (objectui `b2065e711`) + - **minor** — **BREAKING** — Retire the `MobileOverrides` type and its `mobileOverrides` mount point (objectui#4919, maintainer ruling 2026-08-19, ADR-0049 enforce-or-remove). (objectui `2d7304d8a`) + - **minor** — Console half of `ActionSchema.onSuccess` post-success navigation. (objectui `053fdc8f9`) + - **minor** — `ObjectSchemaMetadata` is now derived from `@objectstack/spec`'s `ServiceObject` instead of being a hand-written copy (objectui#5362; maintainer ruling 2026-08-20: the object docu… (objectui `b2ea29717`) + - **minor** — **Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the `aria` member (`{ ariaLabel?, ariaDescribedBy?, role? }`). Its doc comment claimed alignment w… (objectui `18a8e7db7`) + - **minor** — `record:path` stops looking like a control it cannot be. (objectui `0b12a3315`) + - **minor** — **Behaviour change:** the spec's view-level `map` block on a list view is now read at runtime. `ListMapConfigSchema` (objectstack#9340) has been authorable and validated since the… (objectui `e2e8e68f1`) + - **minor** — The console form now renders a refusal message the producer explicitly marked as user-facing, instead of always substituting a generic string (objectui#5210). (objectui `ae61ad4a2`) + - **minor** — The spec-bridge's form-view input now accepts everything `@objectstack/spec`'s FormViewSchema accepts — three key types had drifted narrower, and one of them had inverted (objectu… (objectui `cac64b3f1`) + - **minor** — The console record page and the `record:activity` block read ONE `sys_activity` type table, so a scheduled meeting no longer appears on one and vanishes on the other. (objectui `d06fba8d8`) + - **minor** — The Studio copilot tells the agent WHAT the user is discussing (cloud#1610 send half): `ChatPane` accepts a `surfaceContext` and sends it as `context.surface` on every turn (the t… (objectui `eddc1dd97`) + - **minor** — 设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive… (objectui `1939c9610`) + - **minor** — The built-moment transition (#5799): a build conversation that has produced a WHOLE-APP build auto-routes to `/studio//interfaces` — on live completion and on reopening the c… (objectui `6bd343d29`) + - **minor** — Dashboard flat `table` widget: render the server's grand total as a `tfoot` row (objectui#5846). (objectui `9e725e06a`) + - **minor** — Dashboard `table` widget — a numeric **measure** column now sorts **descending on the first click** (objectui#5845). (objectui `1d9100eeb`) + - **minor** — Studio simplification, first cut (#5813): the Data pillar's object sub-tabs collapse to 记录/表单 with the five power panels (验证/钩子/操作/API/设置) behind one 「高级」 menu (the open panel's n… (objectui `19479a37d`) + - **minor** — One pending-drafts source of truth (#5801): `usePendingDrafts` / `fetchPendingDrafts` replace the five hand-rolled `GET /api/v1/meta/_drafts` copies behind the home banner, Studio… (objectui `469723471`) + - **minor** — Dashboard `table` widget (dataset-backed, flat grouped table) — four rendering fixes so it reads as a table rather than a raw dump (objectui#5827): (objectui `62c0fa164`) + - **minor** — **The page-editor palette now offers the canonical `record:discussion` instead of the `record:chatter` alias.** (objectui `8b1a8f38c`) + - **minor** — `dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshInterval` — so `va… (objectui `2e471a64c`) + - **minor** — **`viewMode` is now declared authoring surface on `ObjectGanttSchema`, and both gantt renderer branches honour it** (objectui#5074, maintainer ruling 2026-08-19: declare-and-wire;… (objectui `199d31bb9`) + - **minor** — Retire `ThemeSwitcherSchema` (`type: 'theme-switcher'`) and `ThemePreviewSchema` (`type: 'theme-preview'`) — the two remaining theme component kinds, which no renderer implemented… (objectui `b7543a9c9`) + - **minor** — **BREAKING** — Split the static `table` column type off the rich shared `TableColumn` (objectui#5474, maintainer ruling 2026-08-22: Option C), so declared = enforced holds per renderer. (objectui `3ccd9e8cb`) + - **minor** — Remove the inert `showRefresh` designer input from the `object-view` registration (objectui#5567). (objectui `4ef29f011`) + - **minor** — `'agenda'` leaves `defaultView` on all three of its declaration faces (objectui#5784 — the `defaultView` sibling of objectui#5740's retirement on `CalendarViewSchema.view`; ADR-00… (objectui `ed8df3e50`) + - **minor** — The 确认修改 (confirm changes) card now carries a UI-owned terminal state after approval (#5695): `detectReplayOutcome` lifts the confirm-replay envelope (`replay_*` tool results) int… (objectui `6ce89daae`) + - **minor** — `validateTree` now reports a dashboard widget `options` key that no renderer consumes as a `unconsumed-widget-option` **warning** naming the consumed set (objectui#5709 ruling). T… (objectui `8d58f46b4`) + - **minor** — **`ReferenceRailEntry` is now the spec's type, and the reference-rail `icon` key retires** (objectui#5494, maintainer ruling 2026-08-22). (objectui `23705b730`) + - **minor** — `'agenda'` leaves `CalendarViewMode` and the zod `CalendarViewModeSchema` (objectui#5740 — the value-level residue of objectui#5667's key-level convergence of `CalendarViewSchema`… (objectui `b55a34647`) + - **minor** — **BREAKING** — Retire `ActionParamDialog`: the `custom` barrel's second action-param dialog is removed, and the app-shell dialog is recorded as the surviving implementation (objectui#5685, maint… (objectui `72ffc3496`) + - **minor** — Localize the theme document types: `@object-ui/types` now owns `Theme`, `ThemeMode` and `ColorPalette` (objectui#5716 ruling, 2026-08-23). The spec retired its theme module (objec… (objectui `c9327c985`) + - **minor** — **BREAKING** — `CalendarViewSchema` (TS interface and zod mirror) converges on the registered `calendar-view` renderer's measured read set (objectui#5667, maintainer ruling option A — the render… (objectui `065bba746`) + - **minor** — Row predicates declare a canon: `record.*`. The bare shorthand and `data.*` now warn once, and are unchanged otherwise. (objectui `d1ab06f0f`) + - **minor** — The metadata designer's View column inspector now reads a column's identity — field key and label — in the ObjectStack canonical spelling only: `field` and `label`. The legacy Tan… (objectui `0e00da354`) + - **minor** — `AuthGuard`'s `requiredRoles` now matches against `user.positions` — the one published spelling (framework ADR-0090 D3) — keeping the `user.role` scalar fallback only for sessions… (objectui `686d5d92c`) + - **minor** — **BREAKING** — Stop showing an access-denied screen to a real administrator while their adminship is still resolving. (objectui `c8da8b326`) + - **patch** — Delete the dead `metadata-admin/previews/object-fields-bridge.ts` module, and the three prose references that still described it as wired. (objectui `dddb942d1`) + - **patch** — `appDesigner.fieldDesigner.formula` is retired — one row removed from each of the ten locale packs plus the designer defaults map, 11 lines, zero readers (objectui#6310). (objectui `00c665ef5`) + - **patch** — The form renderer no longer leaks `FormSchema.previousValues` onto the `` DOM node (objectui#6396). (objectui `f24de8bbc`) + - **patch** — `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` now publish a stylesheet — `"./style.css"`, mapped to `dist/index.css` and compiled at build time from the package's own so… (objectui `062943f86`) + - **patch** — `MasterDetailForm` shows a config hint naming `relationshipField` for a detail collection whose child schema **loaded fine but could not be derived from**, instead of a permanent… (objectui `fd814d6f0`) + - **patch** — `SimpleObjectForm`: consult a declared `submitHandler` before the inline-fields carve-out (objectui `43ca9d5ee`) + - **patch** — `ObjectViewSchema`'s `table` and `form` slots now ship the members they promise (objectui#6269). Both were declared by deriving from the schema they document — `table?: Partial + + objectui range: `190fbd01d061...9602dc820450` + +### Patch Changes + +- daacc10: npm `description` no longer names the fabricated `@objectstack/framework` + + `@objectstack/console`'s manifest `description` is what npm renders on the + package page, so it was the most-read of the three sites that named a package + nobody can install. The earlier pass corrected the other two — this package's + README and `packages/cli/src/utils/console.ts` — but left the manifest alone + because a published manifest was outside that change's declared file surface. + This is the third and last live site. + + The correction is the same one the README took: the mechanism is real and only + the name was wrong. There is no umbrella `@objectstack/framework` package (404 + on the public registry, and fabricated — nothing in this tree presents it as + enterprise or private, it is presented as the *default public* install). What + actually pins the two together is that `@objectstack/cli` declares + `@objectstack/console` as a dependency and both ship at one version from the + Changesets `fixed` group. + + ```diff + -Prebuilt Console SPA pinned to this @objectstack/framework release. Source of truth: … + +Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: … + ``` + + "framework release" survives as the common noun the README already uses; what + is dropped is the `@objectstack/` scope that turned it into a package name. The + `@object-ui/console` source-of-truth half is unchanged — it was accurate. + + Text only: no code, no exports, no behaviour change. It carries a changeset + rather than the publish-nothing exemption because the manifest `description` + ships inside the npm tarball and is a published artifact. +- cebef55: Correct the published `@objectstack/console` README's account of how the CLI + resolves the Console SPA (#11262). `README.md` is in this package's `files` + array, so it is the page npm renders. + + The README claimed `resolveConsolePath()` "prefers `@objectstack/console` and + falls back to `@object-ui/console` when present — so cloud's Docker overlay + (which `cp -r`s its build over `node_modules/@object-ui/console`) keeps + working." Both halves are false against + `packages/cli/src/utils/console.ts`: + + - There is no `node_modules` fallback to the `@object-ui/console` npm + package. Strategy 1 (`require.resolve('@objectstack/console/package.json')` + from the app and from the CLI itself) and strategy 2 (direct + `/node_modules/@objectstack/console`) each require the resolved + `package.json` to be named `@objectstack/console`. The legacy package is + never consulted; the function's own header says so. + - `@object-ui/console` survives in exactly one branch — strategy 3, the + sibling-repo dev fallback, which matches the `name` field of a checked-out + `../objectui/apps/console` **source** tree. That is a source-checkout + probe, not a package resolution, and the corrected text keeps the two + apart. + - The trailing consequence was the load-bearing half: cloud and objectos + Docker images overlay into `@objectstack/console`'s `dist/`, so the README + was telling an operator their overlay works by a mechanism that no longer + exists. Replaced with the real overlay target. + + Documentation only — no runtime, type or export change. The removal of the + npm-package fallback was deliberate and is not restored here. +- a2ec377: Stop naming a package nobody can install: `@objectstack/framework` is not a real + package, and the multi-org remedy now says it is not publicly obtainable + + Four `@objectstack/` names appear in this repo's published docs and runtime text + without this repo building any of them. Measured against the public npm registry + (unauthenticated `GET https://registry.npmjs.org/@objectstack%2F`, with + `@objectstack/spec` and `@objectstack/cli` as positive controls so a 404 is a + fact about the name and not about access), they are **not one population but + three**: + + - `@objectstack/framework` — **404, and fabricated.** Unlike the others, nothing + in this tree describes it as enterprise, cloud, or private; it is presented as + the *default public* install. There is no umbrella package and there never was. + - `@objectstack/security-enterprise` (404) and `@objectstack/organizations` + (404) — **real, and deliberately not public.** This tree calls them + "closed-source" and "cloud-private" in a dozen places, and + `PLATFORM_CAPABILITY_PROVIDERS` declares `security-enterprise` with + `edition: 'enterprise'`. Their 404 is the caveat npm's API carries for any + private package, not evidence of fabrication. + - `@objectstack/service-tenant` — **published, at 4.1.0**, exactly as + `platform-object-names.ts` describes it. Untouched. + + What changes: + + - **`@objectstack/console`'s README** no longer opens with + `pnpm add @objectstack/framework`. The mechanism it described is real, just + misnamed: `@objectstack/cli` declares `@objectstack/console` as a dependency + and both ship at one version from the Changesets `fixed` group, so any app + that installs the CLI — every `npx create-objectstack` scaffold does — already + gets a version-matched Console. The instruction is corrected rather than + deleted, so the reader is left with something they can run. + - **`serve`'s multi-org fail-fast** kept telling an operator to add + `@objectstack/organizations` to their app without saying the runtime ships + only with an enterprise/cloud subscription. That is the un-followable "add it + to your dependencies" that framework#3366 exists to make legible. The remedy + now states it, so an operator without a licence can see that the two bullets + below it are their actual path. The declared-but-unresolvable branch is + unchanged — that operator does have the package. + + No behaviour changes: boot outcomes, exit codes and the posture wall are + untouched, and `@objectstack/security-enterprise`'s install hint is deliberately + left alone — it already names its edition boundary, and the test pinning it is + strengthened to assert that it keeps doing so. + ## 17.2.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index b2e521d650..f0d7d6a926 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.2.0", + "version": "17.3.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 430bc639f5..ecaa4b5d59 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,827 @@ # @objectstack/core +## 17.3.0 + +### Minor Changes + +- 4bd6faa: feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) + + The substrate step (§10.3) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation + machinery once, before the grants cache (#11967) that will consume it, so that + leg does not carry it. **Nothing here caches anything.** + + - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine + middleware seam on every `insert` / `update` / `delete`, ahead of the whole + chain (and so ahead of any `isSystem` bypass a middleware applies). It + generalises the private counter `@objectstack/plugin-security` has carried + since #10757: the mechanism was always the engine's, and hoisting it lets a + second consumer share **one** signal instead of minting a parallel one that + watches a different set of writes. A seam rather than a list of call sites, + because a forgotten call site fails as silent over-permission and writing + through the engine is the only way to write at all — including better-auth's + own adapter. + - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged + in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a + consuming cache carries is the correctness contract; this channel is not.** No + shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a + missed message is *expected*, the bridge stays out of the write path (a + publish failure is logged and swallowed, never awaited by the writer), and the + channel only moves the *typical* convergence from one TTL to one network hop. + That statement lives in the code at the channel, where a consumer reads it. + - **The boot-time posture statement** — non-optional by the ruling. Whenever a + grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no + cross-node invalidation bus, the deployment is told so at `warn`, every boot, + naming the window it accepted and the remedy. It is a statement, not a + refusal: a TTL-bounded per-process cache is a legitimate configuration. It is + said out loud because a silently-absent invalidation bridge is how a security + control gets disabled with nobody noticing (#4785). The in-process `memory` + driver counts as **no** bus — a cluster service exists on the shipped default + while fanning out to nobody, which is the case a "is a cluster service + registered?" check answers `yes` to and is wrong about. + + **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero + subscribers, so nothing is published and nothing is invalidated; with the + shipped default TTL of `0` the bridge attaches nothing and logs nothing above + `debug`. The one composition change worth naming: `Runtime` now registers + `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` + — that is not an oversight, it is the loudest case the posture check has, and + skipping it there would put the statement's absence exactly where the missing + bus is. + + `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads + the engine's epoch when the wired engine exposes one and keeps its private + counter otherwise (test doubles, embeddings). The covered set of writes is + identical — the plugin's own middleware was already global — and it is now + identical *by construction* rather than by two files agreeing on which + operations count. +- 4635f3e: fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) + + + + **BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 + cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — + `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-inert-state-strategies-retired` — where `os migrate meta` users + will look). + + This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 + ruling deliberately kept. That ruling retired the authorable lifecycle-config + container and kept `HotReloadConfigSchema` as a host-driven library parameter + type; this change measures the kept vocabulary's own remainder and finds the + same defect in it. The keep itself stands — `HotReloadConfigSchema`, + `PluginStateSnapshotSchema` and the health vocabularies still export, and + `HotReloadManager` / `PluginHealthMonitor` are untouched. + + The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both + wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said + "memory fallback" — and announced the substitution at DEBUG level only. A host + that asked for durable or cluster-replicated state got process-local memory + and no error: state that does not survive the restart it was configured to + survive. `distributedConfig` had ZERO readers anywhere, so an author could + name a Redis endpoint, a TTL and a replication factor and nothing ever opened + a connection. + + FROM → TO: + + - `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime + behaviour, because `'disk'` already stored to memory. It is the spelling + that was false, not the behaviour. + - `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or + `'none'` to disable state preservation outright. + - `distributedConfig: { … }` → *(removed)* — delete the key. It left with the + `'distributed'` value its own doc comment called it "required" for. + - `DistributedStateConfigSchema` / `DistributedStateConfig` / + `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of + that one key. + + One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete + any `distributedConfig` — you were already getting in-memory state. There is + no in-tree replacement for durable or distributed plugin state; persist it in + the host, which owns the process lifetime these strategies pretended to + outlive. Real disk or distributed persistence returns only via the ENFORCE + route of ADR-0049 — the implementation first, the declaration with it. + + The retirement kit: + + - **enum-value narrowing** (`['memory','disk','distributed','none']` → + `['memory','none']`): invisible to all four ratchets by construction (the + def still emits), so the prescription hangs on the enum's own `error` map + dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` + precedent. A value that was never legal still gets zod's own enum message, + so a typo is not told it "was removed". + - **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable + surface: no metadata-type binding, stack collection or manifest embed ever + carried it, and nothing in the tree parses `HotReloadConfigSchema` outside + its own unit test, so there is no authored document to rewrite and nobody + who could receive a parse-time tombstone): `kernel/DistributedStateConfig` + in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as + a def removal must — `api-surface` −3, `authorable-surface` −8, + `json-schema.manifest` −1. + - **runtime doors** in `@objectstack/core`, because route 3 leaves no + parse-time prescription: `HotReloadManager.registerPlugin` now refuses an + unhonoured `stateStrategy` and a leftover `distributedConfig` with an + ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the + prescription. Refused BEFORE the `enabled` check, so a disabled config + cannot smuggle the false declaration through. TypeScript hosts never reach + it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a + compile error at the call site. + - **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the + #11825 survivor list, so this reverses one line of that ruling on new + evidence — #11825 measured the container's six groups, never this key's own + readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` + moves in the same commit with the reasoning recorded beside it, and asserts + the surrounding keep is intact. + - zero in-tree consumers passed `'disk'` or `'distributed'` (measured at + cdbd9204b6 with a firing positive control; every live caller passes + `'memory'` or `'none'`), so no in-repo source changes ride along. +- ee3595c: fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-watch-placeholder-retired` — where `os migrate meta` users will + look). Graded `minor` rather than `major` for the same reason #12340 was one + day earlier, in this same module. + + ADR-0049 applied one symbol over from #12340, in the same file and on the same + per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and + `PluginStateSnapshotSchema` still export, and `HotReloadManager` / + `PluginHealthMonitor` are untouched apart from the two doors below. + + `HotReloadManager.startWatching` contained **no watcher**. Its whole body was a + guard plus `logger.info('File watching started', { patterns })`, above an + in-source note saying real watching "would require chokidar or similar". Where + #12340's inert fallback at least announced itself at DEBUG, this claimed + success at **INFO**: an operator who set `enabled: true` with `watchPatterns` + and read that line had been told the opposite of the truth. `watchHandles` was + only ever read, deleted, iterated and cleared and **never set**, so + `stopWatching`'s cleanup branch and the teardown loop over its keys were + structurally unreachable rather than merely untaken. `watchPatterns` therefore + had no reader that acted on it — its only two uses were log lines. + + FROM → TO: + + - `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your + globs wherever your own watcher reads them. + - `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`, + called from your own watcher's change handler. That is the debounced + integration point this class does implement, and it is unchanged. + + One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own + file watcher instead of `startWatching` — nothing was ever watched, so nothing + that used to happen stops happening. File watching is the host's job in this + host-driven library; `chokidar` is already a dependency of + `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — + never of `@objectstack/core` — so a host has a working model to copy. + + The retirement kit: + + - **key tombstone**, and the build is what chose it: the plain deletion was + tried first and `gen:schema` gate (a) refused it, because + `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a + silent strip (#3733, ADR-0104) — the very defect being retired, one layer + down. #12340 could take route 3 because what left there was a whole *def*; a + key leaving a *surviving* def has no such exit. So `watchPatterns` is + `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and + `kernel/HotReloadConfig:watchPatterns` is registered by exact key in + `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves + `authorable-surface` only — the def still emits, so `api-surface` and + `json-schema.manifest` do not. + - **no D2 conversion**, deliberately: the chain walks a normalized stack, and + `HotReloadConfig` is not an authorable surface — no metadata-type binding, + stack collection or manifest embed ever carried it — so a conversion would be + a transform with no seam that ever runs. For the same reason the prescription + carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling + in this module does not. + - **runtime doors** in `@objectstack/core`, because nothing in the tree parses + `HotReloadConfigSchema` outside its own unit test, so the tombstone alone + reaches nobody: `startWatching` now throws an ADR-0112 envelope + (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and + `registerPlugin` refuses a leftover `watchPatterns` the same way — before the + `enabled` check, so a disabled config cannot smuggle the false declaration + through. `startWatching` is kept as a throwing door rather than deleted so + that caller meets a prescription instead of a bare `TypeError`. + - **dead code removed with a firing positive control**: `watchHandles` and both + of its unreachable readers are gone. The zero was pinned first — + `reloadTimers.set` resolves a real writer in the same file and the same scan, + while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the + half that always did something (it cancels a pending debounced reload), and + `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles` + and therefore ran zero times. + - **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a + removal: no runtime composes `HotReloadManager`, so enforcing would build for + a caller that does not exist; and a scan of every planning doc returned zero + mentions of hot-reload file watching against 145 control hits in the same + files, so there is no roadmap for `experimental` to point at. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. +- a8c00e2: feat(core): cache successful `sys_setting` localization reads, invalidated synchronously on write (#11966) + + Leg C (ship-first) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0). + `resolveLocalizationContext` re-read `sys_setting` on **every** authenticated + request to answer the same three keys — `timezone` / `locale` / `currency` — + for a workspace whose values change roughly never. That read is now cached. + + **Grade: `minor`, not `patch`.** It adds a deployment variable + (`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code + path. Not `major`: the observable contract callers actually depend on — a + settings write is visible to the very next read — is preserved, and pinned. + + Caching this read was tried once before and reverted. #10221's first version + memoized every outcome for 30s and CI went red on + `analytics-timezone.dogfood.test.ts`, which writes a new org timezone and + expects the very next analytics query to bucket under it; the cache was narrowed + to memoize **failures** only. That verdict was on **TTL-only** caching and it + still stands unamended. What changed is that the process now has invalidation + seams it did not have then: + + - **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)` + dispatches synchronously and in-process from the write path, after the row is + persisted. (⚠️ #11633 calls this a "settings change bus"; no such module + exists — `subscribe()` is the seam. No change was needed in + `@objectstack/service-settings`: the seam was already public and already does + exactly this.) + - **Backstop — the engine write epoch** from #11968's substrate. Needed because + this resolver's own fallback reads `sys_setting` *directly*, so a seeder or + any other direct engine write emits no settings event at all. It is read + structurally rather than imported, because `@objectstack/objectql` depends on + `@objectstack/core` and the substrate declared `WriteEpochLike` separately for + exactly this consumer. A peer node's hint arrives as a local bump, so an + attached `authz.invalidated` bridge narrows cross-node convergence for free. + - **TTL** — the residual bound, for what neither seam can see. Default 30s, + `0` disables the cache on a real path rather than a degenerate one. + + Two rules carry the change and are pinned rather than merely documented: + + 1. **A success is cached only when the engine exposes the write epoch.** A `ql` + with no seam is a `ql` whose writes the cache cannot observe, so rather than + degrade to the TTL-only shape that was already reverted here once, the cache + declines. A partial `{ current }` shape is not a seam either — a counter + nothing can bump would read as a live invalidation source and pin the answer + for a whole TTL. + 2. **Invalidation retires success entries only.** #10221's failure memo exists + for an environment where `sys_setting` is missing; retiring it on a write + would restart precisely the per-request driver log spam that memo removed, + and no write can create a missing table. It stays TTL-bound and behaves + exactly as #10221/#11877 shipped it. + + `analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this + leg's acceptance test, and an ablation that reduces the cache to its TTL turns + it red on the same assertion the original revert was recorded against. +- 7131f12: **Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). + + ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. + + **What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: + + - an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; + - a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; + - an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. + + **One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. + + **The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. + + Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. +- 33184fd: `PLATFORM_ADMIN` can now be anchored on deployment CONFIGURATION instead of a stored grant row: an account whose `sys_user.email` is on `OS_PLATFORM_OWNER_EMAIL` **and** whose `email_verified` reads verified resolves `PLATFORM_ADMIN` with the declared `admin_full_access` capability set, derived live on each authorization resolution (#11663 leg L2, design accepted 2026-08-25 as bundle 1A/2B/3A/4A/5A/6A/7A). + + **Additive — nothing is revoked.** The legacy unscoped `admin_full_access` grant still confers exactly as it did; a holder whose standing rests on the row alone now gets a once-per-process pointer at the configuration line that re-anchors them. A deployment that has declared no administrators resolves byte-identically to before: the config list is empty, the derivation answers "not an admin" before it reads any row, and the pinned batch-equivalence query multiset is unchanged. + + **The variable takes a list.** `OS_PLATFORM_OWNER_EMAIL` accepts one address or a comma-separated list of them — one normalization (`trim().toLowerCase()`), duplicates collapsed, blank entries dropped. ⛔ Any entry that is not an address **fails the whole variable closed** with a loud refusal naming it, rather than being skipped: silently dropping a typo would leave a narrower administrator set than the operator declared, with nothing anywhere to notice. Unset, blank or refused all mean **zero** config-derived administrators. + + **Verified-email match only.** An unverified account holding a configured address confers nothing, and an ABSENT `email_verified` column reads unverified. The match reads the caller's own **stored** `sys_user` row, never the caller-supplied session email. + + New exports from `@objectstack/core`: `resolvePlatformAdminEmails`, `parsePlatformAdminEmails`, `matchesConfiguredPlatformAdmin`, `normalizePlatformAdminEmail`, `PLATFORM_ADMIN_EMAIL_SEPARATOR`, `ADMIN_STANDING_NON_TABLE_INPUTS` and the test hooks beside them. `@objectstack/core` now depends on `@objectstack/types` (measured acyclic: `types` depends only on `spec`). + + `@objectstack/plugin-auth`'s break-glass guard follows the derivation, as it must: `ADMIN_STANDING_SURFACE.sys_user` is reclassified `derives`, the last-administrator enumeration counts config-derived administrators through the resolver's own predicate, and a fifth write shape is judged — a change of address or an `email_verified` reset that would leave the environment with no administrator is refused, naming the configuration as the remedy. An ordinary profile write still costs the guard no reads. +- b72db01: fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescriptions are registered + under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 + semantic entry `plugin-auto-restart-never-reinitialised` — where + `os migrate meta` users will look). Graded `minor` rather than `major` for the + same reason #12340 and #12428 were, the day before, in this same module. + + ## What was measured + + `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped + there. The comment above the call read *"Call destroy and init to restart"*, + and `init` appeared in `health-monitor.ts` **only inside that comment**. So a + plugin whose health checks crossed `failureThreshold` with `autoRestart: true` + got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`, + and periodic health checks that carried on running against the destroyed + instance. The default check when no `checkMethod` resolves is + `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes + indefinitely — so the **terminal** report on a torn-down, never-re-initialised + plugin was `healthy`. + + Reproduced at `ee3595cefd` before anything was changed, with + `successThreshold: 3`: + + ``` + round 1 (failing): status=failed destroyed=0 alive=true + after backoff: status=recovering destroyed=1 alive=false + recovery round 1: status=recovering destroyed=1 alive=false + recovery round 2: status=recovering destroyed=1 alive=false + recovery round 3: status=healthy destroyed=1 alive=false + ``` + + #11955 made that report *more* convincing rather than less: reaching `healthy` + now costs `successThreshold` consecutive passing rounds, so a destroyed plugin + has to earn a declared number of passes before it is misreported. + `restartAttempts` was incremented as though a restart had occurred, and + `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin + that was never brought back up. + + ## Why REMOVE and not the other two ADR-0049 states + + **ENFORCE** would have to build the restart, and the class cannot host one. + `Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call + sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`, + `kernel.ts:607`), both over the full plugin list, with a context that is + `private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain + one, so a host-provided re-init hook would have had nothing to call. (Positive + control for that scan: the same pass resolves five real non-test + `plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a + per-plugin re-init API for a caller that does not exist — no runtime constructs + `PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision + names as the wrong default at this milestone, where the shippable liability is + the false promise and not the missing feature. + + **EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR + corpus returned **zero** mentions of plugin auto-restart, against 118 control + hits for "health" and 13 for "hot reload" in the same corpus. + + `maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than + as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and + *"Backoff strategy for restart delays"* have nothing left to be the vocabulary + **of** — the test that took `distributedConfig` out with the `stateStrategy` + value it was documented as requiring (#12340). + + ## What changes for a host + + All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not + `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a + milder form of the defect being retired. A TypeScript host gets a `tsc` error + (the keys are typed `never`); a parse raises the prescription; and + `PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of + them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown + before any state is stored so a refused config leaves no half-registered plugin + behind. + + `PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that + crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and + left running; acting on that is the host's job in this host-driven library + (#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` + and restart at the level that owns the plugin's lifetime. + + Everything else in the monitor is unchanged: registration, periodic checks, the + `timeout` race and its refd-timer guard (#4875), both failure routes sharing the + counters (#11852), and `successThreshold` binding from every status that records + a failure (#11955). `recovering` is now written only by the success branch — + the one writer that ever meant it. +- 49f0dcf: feat(core): retire the inert `PluginMetadata` surfaces — `configSchema` with `PluginConfigValidator`, and `hotReloadable` (#11982, #12587, ADR-0049) + + + + **BREAKING**: removes a published-but-inert capability from the `.` entry of + `@objectstack/core`. Shipped as `minor` under the lockstep launch-window + convention (a `major` bump is refused repo-wide by `check:changeset-no-major`). + + Removed, each measured at zero live consumers with positive controls (the + sibling `startupTimeout` is read live by the kernel's startup timeout guard); + maintainer ruled retire under ADR-0049 enforce-or-remove, 2026-08-27, + decision-inbox batch 5; recorded in ADR-0025 §3.7: + + - `PluginMetadata.configSchema` — declared "Configuration schema for + validation", but the mechanism could never run: the loader's only call + passed no config, and no caller could — plugin factories close over their + config, so the kernel never receives it. Every one of ~40 production + `kernel.use()` compositions already passes config as constructor arguments + and works. + - `PluginConfigValidator` / `createPluginConfigValidator` — the validator + behind that field: real code with zero reachable invocations, deleted along + with its unit test and its export from the security barrel. + - `PluginMetadata.hotReloadable` — declared "Whether plugin supports hot + reload" with zero reads and zero declarations: `HotReloadManager.reloadPlugin` + gates only on its own registered reload configs, so `hotReloadable: false` + was hot-reloaded identically to `true`. + - The `packages/core/ADVANCED_FEATURES.md` example whose inline comment + promised "Config is validated before init is called" — false on the + retired ref, and the retired surface's only in-repo declaration site. + + One-line fixes, per symbol. If you declared `configSchema` on a plugin: + delete the field and parse your config at the plugin's own seam — + `MyConfigSchema.parse(options)` in the plugin factory or constructor, the + pattern `packages/rest` uses. If you imported `PluginConfigValidator` or + `createPluginConfigValidator`: delete the import and hold your own + `schema.parse` call; the compiler (TS2305) locates every such site. If you + declared `hotReloadable`: delete the field — it never gated anything, and + hot-reload participation remains governed solely by + `HotReloadManager.registerReloadConfig`. + + Re-declaring a kernel-owned config-validation surface is a fresh decision for + the day ADR-0025's plugin distribution layer lands, with #11982's zero-caller + measurement as its starting evidence. + +### Patch Changes + +- 983edf1: fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852) + + `PluginHealthMonitor.performHealthCheck` reaches its failure handling by two + disjoint routes, and only one of them could ever restart the plugin. + + A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`) + incremented `failureCounters`, cleared `successCounters`, and — once + `failureThreshold` consecutive failures accumulated — consulted `autoRestart` + and restarted the plugin. A check that **threw** took a separate `catch` block + that incremented `failureCounters` and stopped there: it never cleared + `successCounters` and never read `autoRestart`. Because `raceCheckTimeout` + rejects rather than resolving, every `timeout` overrun lands in that `catch`, + so a plugin that hung was marked `failed` and never restarted no matter how + many rounds passed or what `autoRestart` said. The severer of the two failure + modes was the one that could not trigger recovery. + + Both routes now funnel into one `recordFailedRound` step that owns the + counters, the `failureThreshold` comparison and the `autoRestart` decision, so + a thrown or timed-out check is restart-eligible on exactly the same terms as a + returned failure. + + The per-route *status* label is deliberately unchanged: a throw is still the + separate `failed` status applied immediately with no threshold, as + `content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and + the restart decision are shared — those are what `failureThreshold` and + `autoRestart` declare, and neither names a route. +- f658793: Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须 + schema-free」) for `@objectstack/core`'s plural→singular store-key fold. + + `@objectstack/spec`: the `defineStack()` manifest-collection vocabulary + (`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`, + `singularToPlural`) moved to a schema-free module and is now ALSO exported + from the sanctioned schema-free entry `@objectstack/spec/meta-spelling` + (widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared` + keeps the same four symbols as re-exports — no consumer-visible removal. The + manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts + (#8424). + + `@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves + from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so + browser consumers of `@objectstack/core` (every `@objectstack/client` bundle) + no longer link the zod schema closure through the store-key fold. +- 2d5cee3: docs(core,service-cluster): retire the two docblocks left stale by `IPubSub`'s corrected delivery guarantee (#12836) + + #12651 corrected `IPubSub`'s contract docblock: delivery is whatever the + configured driver declares, no shipped driver exceeds at-most-once, a missed + message is EXPECTED, and handlers must be idempotent **and** tolerate loss. + Two docblocks elsewhere still described the world before that correction. + + **`@objectstack/core` — `security/authz-invalidation-channel.ts`.** It carried a + paragraph asserting, in the present tense, that the interface docblock "still + says" *At-least-once delivery*, and that repairing it was a `packages/spec` + change filed separately. That filing was #12651 and it has landed, so the + paragraph is now false rather than merely stale — it sends the next reader + looking for a live disagreement between the interface and the drivers that no + longer exists. Replaced with a plain pointer to the interface docblock. + Everything else in that docblock is unchanged: the at-most-once reasoning, the + TTL-is-the-bound rule, and the best-effort-at-the-publish-site note all still + hold. + + **`@objectstack/service-cluster` — `memory/pubsub.ts`.** The line "At-least-once + semantics held vacuously (a single in-process delivery)" was wrong on its own + terms even before #12651: the same docblock states that handler errors are + swallowed and logged via `onError`, so a handler that throws loses the message + with no retry and no persistence. That is not at-least-once in any sense, and + "vacuously" does not save it. Replaced with the honest statement — one + synchronous in-process delivery attempt per subscriber, no persistence, no + retry, no replay. + + Prose only. No behaviour change, and no test changed. +- a17da05: fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877) + + `resolveLocalizationContext` memoizes an outcome for 30s whenever the read + "failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run, + and the driver does not re-log it, on every request). The write condition was + wider than the cache's own docblock: six legs set the flag and only **one** of + them is the backend fault the docblock describes (the direct `ql.find` throw). + The other five are the **settings service refusing** — a thrown `getMany`, each + of the three older per-key `get`s, and the whole-block "service unavailable" + handler. + + Those five legs are reachable inside the settings engine's **bind window** + (`SettingsService.getMany` refuses all-or-nothing for a `localization` + namespace whose manifest is not yet registered), so: + + - A caller that deliberately re-reads **after** the bind — the #11580 stdio + repair re-resolves at `kernel:bootstrapped` for exactly this reason — was + answered from the memo taken **inside** the window for up to 30s. The + correction silently did not happen, with nothing in the output saying so. + - A settings refusal standing alongside a perfectly **successful** direct read + memoized that successful value — the staleness the docblock forbids outright + and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch. + + The memo is now written only for the direct-read fault. **#10221's protection + is unchanged for the legs it was built for**: its environment (table not + migrated yet) still memoizes, because the direct read throws there whether or + not a settings refusal stands in front of it — pinned in both directions. And + nothing is lost on the narrowed legs: those refusals throw out of an in-memory + registry check *before* any query and *before* any log line, so memoizing them + suppressed neither. + + No signature, export or accepted-input change — the flag is internal to the + module. +- f7b25c5: fix(core): `successThreshold` now binds from every status that records a failure, so a declared count above 2 stops being unreachable (#11955) + + `PluginHealthMonitor` consulted `successThreshold` only while a plugin's status + was `unhealthy` or `degraded`. The first success in a recovery wrote + `recovering` — a status that gate did not name — so the **second** success took + the outer `else` and went straight to `healthy` without the counter being read + at all. `failed` was in neither set either, so a plugin whose check threw + recovered on its **first** success. + + The declared value was therefore capped in practice: + + | Status when the successes start | Consecutive successes actually required | + | :--- | :--- | + | `unhealthy` / `degraded` | 2, whatever `successThreshold` said | + | `failed` / `recovering` | 1, whatever `successThreshold` said | + + A declared `successThreshold: 5` was indistinguishable from `2`. The default is + `1`, which is exactly the value at which the defect is invisible — every + declared value above it was the one that misbehaved. + + The counter is now consulted on the way out of every status that records an + observed failure — `degraded`, `unhealthy`, `failed` and `recovering` — so + `successThreshold: N` requires N consecutive successes from each of them, as + its declaration says ("Consecutive successes needed to mark healthy"). The gate + is a map that is exhaustive over `PluginHealthStatus`, so a status added to the + spec fails to compile until it is placed on one side or the other; that is what + `recovering` slipped through before. + + `healthy` and `unknown` still promote on the first success, deliberately: the + count is declared as a **recovery** criterion ("Number of consecutive successes + to recover from unhealthy state") and neither of those records a failure to + recover from — `unknown` is the status `registerPlugin` writes before any check + has run. + + **Behaviour change, only for configs that declare `successThreshold` above 1.** + At the default `1` every route is byte-for-byte what it was: one success has + always been enough and still is. A plugin declaring a higher count now takes + the number of consecutive successes it asked for before it is reported + `healthy`, including after a `failed` round and after an `autoRestart`. + + This also makes #11852's `successCounters` reset load-bearing. That fix cleared + the counter on the thrown failure route, and could not be pinned: the counter's + only read site was unreachable with a stale non-zero value, so any test would + have passed for the wrong reason. With `failed` gated on the counter, a throw + that interrupts a recovery now demonstrably starts the count over. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 6f7b6247ac..f1bfbb5a96 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.2.0", + "version": "17.3.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 39605c0caf..e69af5c1aa 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,98 @@ # create-objectstack +## 17.3.0 + +### Minor Changes + +- 366f895: feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) + + The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, + `/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, + replaced by seven new models and a three-way connection contract. This lands + the migration atomically: + + - **Seven new platform objects** back the stable models — + `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, + `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, + `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, + registered in the platform-object-names registry, listed in + `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose + `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group + provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — + is closed). + - **SCIM connections stay runtime data.** The stable constructor is satisfied + with an application-owned `authentication.verifyBearerToken` that resolves + the connection from a row at request time — not static boot config, and not + the upstream `managedConnections` catalog (deliberately not adopted). + - **ObjectStack owns SCIM credentials outright** (stable upstream stores no + credential at all): `sys_scim_connection_credential` plus + `scim-connection-service.ts` mint/digest/verify. At rest only an + HMAC-SHA-256 keyed by the deployment auth secret (base64url, + domain-separated) is stored — at parity or better than the rc.1 unsalted + SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live + 401 paths for forged, revoked and expired bearers. + - **The ObjectQL better-auth adapter gains native transactions** + (`engine.transaction`, fail-closed on drivers without `beginTransaction`), + which stable scim requires by assertion for atomic provisioning writes. + - **Scaffold suppression retired**: the `@better-auth/scim>better-call` + `allowedVersions` entry (CLI renderer + blank template) is gone — stable + 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped + to absence pins. The `better-auth>better-sqlite3` and four + `@better-auth/utils` entries stay; their retirement conditions are separate + and unmet. + - The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers + `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace + overrides' silencing would "satisfy" while the family is 1.7.1. Floating is + its own follow-up. + + **Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token + endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — + but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a + documented "do not let the IdP push groups" boundary, and the maintainer ruled + (2026-08-25) that SCIM has no real customers and old data need not carry: the + one binding constraint is that an existing system upgrades smoothly, which it + does — every table the installed library can write exists at this version, and + SCIM-disabled deployments see no behavior change. A major would move the whole + fixed version group for a feature surface with zero consumers. Deployments + that had SCIM enabled must mint new connection credentials (digests are not + portable from rc.1 on any path — IdP token reissue is a migration-day + operator action regardless of semver level). `sys_scim_provider` itself is + NOT removed here; its retirement is tracked separately (#11757). + +### Patch Changes + +- c6c7fec: fix(create-objectstack): stop reporting a failed pnpm probe as a deliberate npm choice (#11616) + + `detectPackageManager()` was `try { execSync('pnpm --version') } catch { return + 'npm' }`, so every failure mode collapsed into one answer. `npm install` in the + scaffolder's output meant either *this machine has no pnpm* or *the probe + threw*, and nothing — no log line, no message — could tell the two apart. + + That second case is reachable on an ordinary developer machine, not just in + theory: `pnpm --version` resolves through Corepack and therefore depends on the + directory it runs in. Measured on one machine, one binary, two directories — + `10.31.0` inside a repo that pins `packageManager`, `10.33.0` outside it, where + Corepack has to resolve, and may have to fetch, a version nothing pinned. A + user who has pnpm installed but is on a slow or offline network was silently + told to run npm. + + The probe now reports why as well as what: + + - `probe: 'ok'` — pnpm answered, so pnpm is used (unchanged, silent). + - `probe: 'absent'` — no pnpm on PATH at all, so npm is a real choice + (unchanged, silent). + - `probe: 'failed'` — pnpm **is** on PATH and the probe still threw. npm is + used exactly as before, and the run now says so, naming the underlying + failure: `pnpm is installed but \`pnpm --version\` failed (); using + npm as a fallback.` + + **Which package manager a run uses is unchanged in all three cases** — it is + still pnpm if and only if the probe succeeded. The PATH lookup that separates + `absent` from `failed` runs only after the decision is already made and feeds + the message alone, so a miss there can change a warning's wording and never the + tool's behaviour. The only output that moves is one warning in a case that was + previously silent and wrong. + ## 17.2.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 5d1d60eca2..bb6cdb946e 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.2.0", + "version": "17.3.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 202a3653bc..3f3e415e50 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/driver-memory +## 17.3.0 + +### Minor Changes + +- aa3f9ba: fix(drivers): a declared field written as an explicit `undefined` is indistinguishable from one never written (#9276) + + A row has exactly two states to say about a field, each with a defined meaning: + **the key is absent** ("no value was ever written") or **the key holds a + value**. An own key holding `undefined` is neither. Only a JS-backed driver can + emit it — a SQL NULL arrives as `null`, which is a value — and every consumer + downstream had to invent a reading of it. Measured on `origin/main`, they did + not agree: `has(record.f)` on the real `@objectstack/formula` CEL engine reads + it as ABSENT, `materializeDeclaredFields` reads it as ABSENT by documented + design, and a bare `f in row` reads it as PRESENT. + + Both JS-backed drivers were measured separately, and they did **not** match: + + - **`driver-memory`** preserved the own key holding `undefined` through + `create` and handed it back from `find`. Its own projection path and its own + matcher already read the shape as absent (`projectFields` skips `undefined` + values, `{f: {$exists: true}}` excluded it, `{f: {$null: true}}` included it) + — so the returned row was the only surface in the driver still claiming the + key was present, and the same stored row answered `'f' in row` differently + depending on whether a projection was requested. + - **`driver-mongodb`** SPLIT. `create()` returns the object it built in + process, so the field came back as an own key holding `undefined`; but the + MongoClient default is `ignoreUndefined: false` and this driver sets no + override, so BSON stored `null` for that same field and a subsequent `find()` + answered `null` — a value. One write, two answers, from one driver. + + Both drivers now drop own keys holding `undefined` on the way into storage, so + a declared field written as `undefined` and one never written are the same row: + deep-equal, same own keys, same answer to every presence test. `null` is + untouched and stays a value. + + Fixed at the producer rather than at each consumer: converging one consumer + resolves one seam, but the next consumer that reasons about key presence + re-acquires the problem. + + **Behaviour that changes, precisely.** What these two packages RETURN for one + input class, and what `driver-mongodb` STORES for it. A caller passing an + explicitly-`undefined` property to `create`/`bulkCreate`/`update`/`updateMany` + (or seeding `initialData`) no longer sees that key in the returned row, and no + `null` is written for it in MongoDB. `undefined` does not survive JSON, so this + shape cannot arrive over the wire — reaching it requires in-process code. + + **What does NOT change.** No accept set moves: no schema, refine, validator or + public type is touched, nothing that parsed before is refused now, and no + exported name is added, removed or moved. Filter results are unchanged in both + drivers — measured identical before and after for `$null` / `$exists` / + equality on `driver-memory`, and on `driver-mongodb` `$null: true` lowers to + `$eq: null` and `$null: false` to `$ne: null`, which MongoDB matches + identically against a missing field and a stored `null`. + + Scope on `driver-mongodb` is the INSERT doors and the values returned. + `$set`-shaped patches are deliberately untouched: changing them would answer + "what does a patch carrying `undefined` mean — clear the field, or leave the + prior value standing" which is a storage-contract question, not this repair's + to settle. On `driver-memory` the normalisation is applied POST-merge for the + same reason — it keeps today's answer (every measured consumer read the merged + own-key-`undefined` as "absent", and the row now says absent outright) rather + than silently turning such a patch into a no-op. + +### Patch Changes + +- f6fa22c: `min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. + + FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). + + The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 88d91bbaf0..d514e66946 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.2.0", + "version": "17.3.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 1049e71da8..0081e16ea5 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,375 @@ # @objectstack/driver-mongodb +## 17.3.0 + +### Minor Changes + +- aa3f9ba: fix(drivers): a declared field written as an explicit `undefined` is indistinguishable from one never written (#9276) + + A row has exactly two states to say about a field, each with a defined meaning: + **the key is absent** ("no value was ever written") or **the key holds a + value**. An own key holding `undefined` is neither. Only a JS-backed driver can + emit it — a SQL NULL arrives as `null`, which is a value — and every consumer + downstream had to invent a reading of it. Measured on `origin/main`, they did + not agree: `has(record.f)` on the real `@objectstack/formula` CEL engine reads + it as ABSENT, `materializeDeclaredFields` reads it as ABSENT by documented + design, and a bare `f in row` reads it as PRESENT. + + Both JS-backed drivers were measured separately, and they did **not** match: + + - **`driver-memory`** preserved the own key holding `undefined` through + `create` and handed it back from `find`. Its own projection path and its own + matcher already read the shape as absent (`projectFields` skips `undefined` + values, `{f: {$exists: true}}` excluded it, `{f: {$null: true}}` included it) + — so the returned row was the only surface in the driver still claiming the + key was present, and the same stored row answered `'f' in row` differently + depending on whether a projection was requested. + - **`driver-mongodb`** SPLIT. `create()` returns the object it built in + process, so the field came back as an own key holding `undefined`; but the + MongoClient default is `ignoreUndefined: false` and this driver sets no + override, so BSON stored `null` for that same field and a subsequent `find()` + answered `null` — a value. One write, two answers, from one driver. + + Both drivers now drop own keys holding `undefined` on the way into storage, so + a declared field written as `undefined` and one never written are the same row: + deep-equal, same own keys, same answer to every presence test. `null` is + untouched and stays a value. + + Fixed at the producer rather than at each consumer: converging one consumer + resolves one seam, but the next consumer that reasons about key presence + re-acquires the problem. + + **Behaviour that changes, precisely.** What these two packages RETURN for one + input class, and what `driver-mongodb` STORES for it. A caller passing an + explicitly-`undefined` property to `create`/`bulkCreate`/`update`/`updateMany` + (or seeding `initialData`) no longer sees that key in the returned row, and no + `null` is written for it in MongoDB. `undefined` does not survive JSON, so this + shape cannot arrive over the wire — reaching it requires in-process code. + + **What does NOT change.** No accept set moves: no schema, refine, validator or + public type is touched, nothing that parsed before is refused now, and no + exported name is added, removed or moved. Filter results are unchanged in both + drivers — measured identical before and after for `$null` / `$exists` / + equality on `driver-memory`, and on `driver-mongodb` `$null: true` lowers to + `$eq: null` and `$null: false` to `$ne: null`, which MongoDB matches + identically against a missing field and a stored `null`. + + Scope on `driver-mongodb` is the INSERT doors and the values returned. + `$set`-shaped patches are deliberately untouched: changing them would answer + "what does a patch carrying `undefined` mean — clear the field, or leave the + prior value standing" which is a storage-contract question, not this repair's + to settle. On `driver-memory` the normalisation is applied POST-merge for the + same reason — it keeps today's answer (every measured consumer read the merged + own-key-`undefined` as "absent", and the row now says absent outright) rather + than silently turning such a patch into a no-op. + +### Patch Changes + +- f6fa22c: `min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. + + FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). + + The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. +- e062370: `driver-mongodb` refuses an aggregate function it does not lower, instead of + answering it as a silent SUM (#12818). + + `buildAccumulator`'s `switch` on `agg.function` ended with + `default: return { $sum: fieldRef ?? 0 }`, so ANY name this driver does not + lower — a typo (`median`), a miscased spelling (`COUNT_DISTINCT`), a function + added to the contract but not to this file, or an unnarrowed `method` arriving + from `StrategyContext.executeAggregate` (#12776) — was answered as a **sum of + that column**, under the alias the caller asked for, with no error, no envelope + and no log. It is the worst available answer precisely because it is + arithmetically plausible: a dashboard tile renders the number without complaint, + so nothing downstream can tell "your function ran" from "your function was + silently replaced". The field-less spelling was quieter still — `{ $sum: 0 }`, + i.e. `0`, which reads as "no matching rows". + + The refusal is the two-class ADR-0112 envelope both SQL faces already answer + with (#5907), first sentence for first sentence, so one condition cannot have + two wire identities depending on which backend served it: + + - a name the Query Protocol does not declare answers `INVALID_QUERY` / **400** + and names the declared vocabulary (`@objectstack/spec AggregationFunction`); + - a DECLARED name this backend does not lower answers `NOT_IMPLEMENTED` / **501** + and names what it does lower. That class is empty today — every member of + `AggregationFunction` lowers here — and is pinned as a positive assertion, so + the day the spec grows a function this driver misses, the suite goes red + rather than quietly stopping to cover anything. + + Judged case-sensitively, which is what the enum is: `COUNT_DISTINCT` is not + `count_distinct`, and telling its author the backend has a capability gap would + be false. + + **Graded `patch`, deliberately.** No correct query's answer moves: all six + declared functions and the two retired ones this face still lowers + (`array_agg` / `string_agg`, an existing divergence from the SQL faces, recorded + and filed as #13075 rather than closed here) are byte-identically unchanged, + pinned by controls that compute their values in the same suite. The only inputs + whose behaviour changes are ones this driver was already answering *wrongly*, so + there is no working capability being removed — the same shape, in this same + package, that #10576's per-aggregation-`filter` refusal shipped as a patch. + + Nothing to migrate. A caller that was reaching the old `default` arm was reading + a SUM in place of the function it asked for; the refusal now names the function + and the remedy. +- c4ecf0c: fix(driver-mongodb): a boolean aggregand answers the ruled values (#11151) + + `sum` and `avg` over a **boolean** column answered `0` and `null` on this + driver, where every SQL dialect (#11635), `driver-memory` (#11065) and + objectql's in-memory fallback already answered `3` and `0.5` over the same + 3-true/3-false rows. The lowering passed the boolean straight to MongoDB's + `$sum` / `$avg`, which are arithmetic accumulators and ignore every non-numeric + value: with nothing numeric to fold, `$sum` returns its identity `0` and `$avg` + returns `null`. Both arms now wrap the aggregand in the boolean-only `$cond` + coercion #11065 landed, so a rate measure over a flag column reads the same on + this driver as on the others. + + **⛔ `min` / `max` are deliberately NOT coerced.** They are order statistics + over BSON canonical comparison order, which ranks booleans and returns a member + of the input domain — #11249 ruled they answer `false` / `true`, and coercing + them would have answered `0` / `1`, breaking that contract in the opposite + direction from the defect being fixed. Their lowering is unchanged; a pin reads + the emitted stages to keep it that way. + + **The coercion stays boolean-only.** `null`, a missing key and a non-numeric + string reach the accumulators exactly as before and stay excluded. Widening to + the other half of objectql's `toNumber` — which maps a non-numeric string to + `0` — would average garbage as zero rather than excluding it, a separate + question this change does not open; a control pins the exclusion. + + **Why `patch` and not `minor`.** This changes what an existing operation + returns, which ordinarily argues for `minor`. It is graded `patch` because the + returned values were **already ruled** before this change (#11065 for the + arithmetic pair, #11249 for the order statistics) and are stated as shared + values in `@objectstack/spec/data`; every other face already produced them, and + the sibling repair on `driver-memory` shipped as a patch. There is no new API, + no option, and no opt-out to describe — nothing here is a feature, and the only + behaviour a consumer could have depended on is a value this project has ruled + wrong and that no other driver produces. Calling it `minor` would advertise a + capability that does not exist and imply the old answer had standing. + + Not user-visible, and shipped in the same change because the two are one cell: + `mongodb-pipeline-evaluator.testkit.ts` — the server-free instrument that holds + this lowering to the shared table — applied its "arithmetic accumulators ignore + non-numeric values" filter to `$min` / `$max` as well, one arm too far, and so + answered `null` for them over a boolean column while the lowering under test was + correct. Those arms now ignore only null and missing, compare by BSON canonical + order, and refuse a type the evaluator does not rank instead of silently + answering `null`. +- 795d14e: fix(driver-mongodb): refuse the retired `array_agg` / `string_agg` instead of lowering them (#13075) + + `buildAccumulator` still carried `case 'array_agg'` and `case 'string_agg'` + arms — both lowering to `$push` — plus a matching `string_agg` join in + `postProcessAggregation`. Both names left `AggregationFunction` at **#6188** + under ADR-0049 enforce-or-remove, and both SQL faces have refused them as + class-1 undeclared names ever since (`driver-sql`'s `refuseAggregateFunction`, + `driver-turso`'s `RemoteTransport`, each `INVALID_QUERY` / **400**). + `driver-mongodb` was the only face still answering them, so **one query got a + 400 on two backends and a `$push` array on the third** — the local/remote fork + #5907 exists to prevent, one vocabulary later. + + Why this face kept them when `objectql`'s in-memory fallback deleted its arms + for the same two names at #6188: that fallback switches on the **enum type**, so + `case 'array_agg'` there stopped type-checking the moment the value left the + enum. `AggregationInput.function` here is a bare `string` — the driver's own + `aggregate` reads aggregations through an `any` cast — so these arms compiled + fine and survived the retirement unnoticed. + + Both names now answer `INVALID_QUERY` / **400**, answer-for-answer parity with + both SQL faces. They are named explicitly rather than left to fall through. + When this change was written, falling through was not safe at all: + `buildAccumulator`'s `default` arm answered `{ $sum: … }`, so deleting the arms + alone would have turned a visibly-wrong ARRAY into an arithmetically PLAUSIBLE + NUMBER — strictly the worse failure, and exactly the defect #13076 has since + fixed in that arm (#12818). Naming them was correct whichever order the two + landed in, and now that #13076 is on `main` the named arm still draws the + distinction `AggregationFunction`'s own error map draws: a caller who bypassed + the parse door is told the name was **removed** at #6188, which is a different + fact from `default`'s "is not a declared aggregate function". Both producers are + kept for that reason. + + The retirement prescription itself is not restated here — it lives once, on the + enum's error map in `@objectstack/spec`, and a copy in the driver would be a + second wording of one vocabulary with nothing keeping the two in step. + + **Graded `patch`, deliberately.** No correct query's answer moves: all six + declared functions are byte-identically unchanged, pinned by a positive control + in the same suite that walks `AggregationFunction.options`. `AggregationNodeSchema` + already rejects both spellings at the parse door, so the only callers whose + behaviour changes are ones reaching the exported builder or the driver's + `aggregate` directly — and they were reading a value the protocol has no name + for. Nothing to migrate: read the rows with an ordinary `fields` query and shape + them in the caller, or model the roll-up as a stored field. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 7c6bc7c37b..8f0e375d18 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.2.0", + "version": "17.3.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 e627f35b9c..75cfdaf181 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,1478 @@ # @objectstack/driver-sql +## 17.3.0 + +### Minor Changes + +- 0010797: Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). + + `IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and + only when — the referenced parent table lives outside the introspecting session's resolution + scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's + database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot + cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a + separate key, never a conditional spelling. + + `convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target + carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` + (default `console`) instead of being wired to the bare name — which either resolved to nothing + or to a same-named table in the current schema, silently. The column is kept as a plain field so + its data stays visible. Foreign keys with in-scope targets keep producing identical lookup + fields. +- 09f9361: Report a multi-value field left on a stale `varchar`/`text` column, instead of + letting it silently corrupt every array written to it + + A field that gains `multiple: true` materialises as a `json` column on a fresh + database, but `initObjects` is additive-only: on a database created while the + field was single-value, nothing is missing, so nothing is added and the old + `varchar`/`text` column is kept forever. The write path stringifies the array + for a json field on every non-SQLite dialect; the read path relies on the + driver's column-type-based decoding, which a stale textual column defeats. The + array goes in as the literal `["id1","id2"]` and comes back as a **string** — + so a hook copying the value into a child record's single-lookup column writes + that whole string as one id. User-filed production report, repaired by hand on a + live database. + + Until now the schema-drift detector said **nothing** about it. Measured on the + pre-fix tree against live Postgres 16.13 and MySQL 8.0.46: after the metadata + change and a reboot, `detectManagedDrift()` returned `[]` and the boot logged + zero `[schema-drift]` lines, while the very next write stored + `["user_A","user_B"]` into a `character varying(255)` column and read it back + with `typeof === 'string'`. The action vocabulary had no "the base type is + wrong" entry at all — only `relax`/`tighten_not_null`, `widen`/`narrow_varchar`, + `drop_column`, `drop_column_default` and the index ops. + + The divergence is now **detected and reported**, naming the table, the column, + the declared type, the physical type and the exact statement an operator runs by + hand — dialect-correct, and executed against both live servers by the suite + rather than merely printed. ObjectStack does **not** change the column: an + `ALTER TABLE … TYPE json USING …` over existing rows with an index drop and + rebuild is a destructive migration over shipped data, and whether the platform + should perform it is a separate, open decision. The new `manual_column_type_change` + op deliberately has no reconciler arm; `applyMigrationEntries` reports it as + skipped, which is the intended contract while that decision is open. + + Reported at severity `error` and category **`needs_confirm`**, and the category + is load-bearing rather than cosmetic. Every database this finding describes is + already serving — that is the premise of the report — and the artifact-pinned + boot gate refuses a boot for `category === 'destructive'` and nothing else + (`severity` it never reads). Measured both ways: a `destructive` entry returns + `ok=false` from that gate, this entry returns `ok=true`. Spelling it + `destructive` would have turned every affected deployment into a crash-loop on + its next restart — the report of the corruption becoming the outage. + + SQLite is deliberately excluded, and the exclusion is a measurement rather than a + scoping convenience: the same stale column reads back as a real `['x','y']` + array there, because SQLite's read path `JSON.parse`s regardless of what the + column calls itself. There is no corruption to report, and reporting it anyway + would put a permanent `error` finding on every long-lived SQLite development + database. A stale `integer`/`timestamp` column is excluded for the mirror-image + reason — the server already refuses that write loudly, so there is no silence to + break. + + Also fixed, same defect class: a multi-value field that *also* declared + `maxLength` used to produce `narrow_varchar` at severity `error`, category + **destructive** on both enforcing dialects — a finding that refuses the + artifact-pinned boot and invites `os migrate apply --allow-destructive` to + rewrite the column to `varchar(50)`, the exact opposite of the repair it needs. + `createColumn` returns at its `multiple` branch before `maxLength` is ever read, + so the emitter never asks for that width; the differ no longer does either. The + single-value width branch is untouched and pinned as untouched. +- 34d3011: Report an **unbounded** text-family field left on a pre-existing `varchar` + column, instead of leaving the operator with a refused write and no diagnostic + + After #11875/#12119 a **newly created** `signature` / `qrcode` column is TEXT and + holds a data URI correctly. `initObjects` is additive-only, so on a database + created by an earlier release nothing is missing, nothing is added, and the old + `varchar(255)` column is kept forever — the boundary #12119's own changeset + states in as many words. What was not stated is what the drift reporter did + about it, and the answer was **nothing**. + + The varchar differ's entire branch required `declaredMaxLength !== undefined`, so + on a pre-existing table it split the text family by whether its author had + written a number: + + ``` + Field.signature({ maxLength: 4096 }) over varchar(255) -> widen_varchar reported + Field.signature() — no bound over varchar(255) -> (nothing) silent + ``` + + The second row is the common case. Measured on the pre-fix tree, one + `diffManagedTable` call per type on dialect `postgres` against a `varchar(255)` + column: `text` / `textarea` / `html` / `markdown` / `richtext` / `code` / + `signature` / `qrcode` with no `maxLength` each returned **zero** entries, while + `{ type: 'signature', maxLength: 4096 }` over the same column returned exactly + one `widen_varchar` in the same run — so the differ was working and this shape + was simply invisible to it. An upgrading deployment therefore saw no change and + no diagnostic, while the server kept refusing the same write; and the refusal is + a poor substitute for a report, because the live probe behind objectql's + `driver-fault-redaction.ts` measured Postgres's `22001` as identifier-only and + naming the **type** rather than the column (`value too long for type character + varying(255)`). + + The divergence is now **detected and reported** under a new report-only + `manual_widen_varchar_to_text` op, naming the declared type, the physical width, + the consequence, and both operator routes. Same `declared ≠ enforced` shape as + the #11374 / #11431 / #11875 family, closed one door further along — at the + migration seam rather than the authoring or write seam. + + **Nothing is migrated for you, and nothing new is refused.** There is no + reconciler arm: `os migrate apply` reports the entry as skipped, exactly as it + does for `manual_column_type_change`. The entry is `category: 'needs_confirm'`, + so the artifact-pinned boot gate — which refuses a boot for `destructive` and + nothing else — is unaffected: a deployment that merely refuses over-long values + must not become a crash-loop on its next restart. Dev auto-reconcile takes + `safe` only, so it never applies this unattended either. SQLite is excluded: it + enforces no declared width, so there is no divergence to report. + + `manual_widen_varchar_to_text` is a **distinct** op rather than a second use of + `manual_column_type_change`, for a measured reason: `os migrate + multi-value-columns` selects its entire population by + `op.type === 'manual_column_type_change'` and recovers the dialect by matching + the message against `manualJsonConversionSql`, so sharing the op would hand this + finding to a command whose remedy makes the column `json` — and, the message + carrying no json statement, have it refused as `remedy_not_recognized` on every + run. + + Graded `minor` rather than `patch` on two counts, matching the sibling drift-op + addition that shipped for #11535: `detectManagedDrift` emits a finding on + existing deployments where it previously emitted none (visible in `os migrate + plan`, in `os migrate apply`'s skipped count and in the boot-time + `[schema-drift]` warn), and the exported `DriftOp` union gains a member, which is + additive for producers but widens a type any consumer switching exhaustively + over it must account for. Nothing is removed, renamed or newly rejected, so it is + not a breaking change. +- d0e3a88: **Fix:** a text-family field that a declared index keys on is emitted as `varchar(maxLength)` instead of an unbounded `TEXT`, so the index MySQL previously refused can actually be created (#11374). + + `createColumn` mapped the whole text family (`text` / `textarea` / `html` / `markdown`) to an unbounded `TEXT`, ignoring the field's own declared `maxLength`. MySQL refuses a `TEXT`/`BLOB` column in a key without a prefix length, and the two halves of schema-sync fail *separately*: the `CREATE TABLE` succeeds, then `ALTER TABLE … ADD [UNIQUE] INDEX` fails with `ER_BLOB_KEY_WITHOUT_LENGTH`. The table therefore lands on disk **without the constraint it declared**, and the object stays registered-but-broken. Measured on a live MySQL 8.0.46: **36 of the 44 platform objects** failed schema-sync this way, so a stack whose `default` datasource is MySQL could not stand up its own schema — the dev-admin seed never landed and first sign-in returned `401 INVALID_EMAIL_OR_PASSWORD`. Honouring the declared bound takes that to **12**. + + **The bound is the field's own `maxLength` — nothing is invented.** `schema-drift.ts` already treated `varchar(field.maxLength)` as the expected physical shape of a bounded field (its `widen_varchar` / `narrow_varchar` ops say so in as many words); this is the emitter finally agreeing with the differ. On MySQL that removes a permanent destructive drift finding: `columnInfo()` reports `maxLength: 65535` for a `TEXT` column, so every bounded text field already reported `narrow_varchar` ("metadata caps at 32 chars but the column allows 65535") against a column the driver itself had created. + + **Scope, both halves load-bearing.** The bound is emitted only for a column some declared index **keys on** — a non-indexed `Field.text({ maxLength: 65000 })` stays `TEXT`, because `varchar(65000)` on utf8mb4 is 260000 bytes and would blow MySQL's 65535-byte row limit, turning a working table into an un-creatable one. And only where the bound is **usable as a key part**: `maxLength` absent, or wider than 768 characters (3072 index bytes ÷ 4 bytes per utf8mb4 character — measured: `varchar(768)` takes a unique index, `varchar(769)` is refused with `ER_TOO_LONG_KEY`), leaves the column `TEXT` and the index refused with a message naming the field and the declaration that fixes it. + + **⚠️ Graded `minor`, not `patch`: this changes declared behaviour on newly created tables.** A keyed bounded text column now enforces its declared length where the dialect enforces `varchar` (Postgres and MySQL), so a write longer than `maxLength` that previously landed in an unbounded `TEXT` is now refused — under `STRICT_TRANS_TABLES`, with `ER_DATA_TOO_LONG`. That is the declaration becoming enforced rather than a new restriction, and it is exactly what makes the column indexable, but it is a behaviour change and is named here as one. **Existing tables are unaffected**: schema-sync is additive and never rewrites a column that is already present. + + **A prefix index is deliberately NOT substituted for an unkeyable column.** For an ordinary index that would be a transparent access-path choice, but for a `UNIQUE` one it silently replaces the declared constraint with a stricter one — uniqueness of the *prefix*. Measured on MySQL 8.0.46 with `UNIQUE KEY (token(191))` and two distinct 200+ character tokens sharing their first 191 characters: the second insert was rejected with `ER_DUP_ENTRY` **even though the tokens differ**. On `sys_session.token` that is a valid sign-in refused as a duplicate. The refusal an operator can read is strictly better than a constraint that quietly means something else. +- 64505a5: fix(driver-sql): stamp `updated_at` at the audit column's own precision on MySQL, so an updated row stops reading as modified BEFORE it was created (#11224) + + `createAuditTimestampColumn` builds the audit columns on MySQL as `DATETIME(3)` + defaulted with `now(3)`, and its docblock says why in as many words + ("`CURRENT_TIMESTAMP` has to carry matching precision for a `DATETIME(3)` + default", #3942). `updatedAtStamp()` — the value every UPDATE door writes into + that same column — was a bare `knex.fn.now()`, which compiles to an unqualified + `CURRENT_TIMESTAMP` that MySQL truncates to whole seconds. So the column was + created at millisecond precision on purpose and then written at second precision. + + Measured on live MySQL 8.0.46, against the exact schema the driver produces: + + ``` + created_at updated_at delta + before CURRENT_TIMESTAMP 2026-08-23 10:22:36.799 2026-08-23 10:22:36.000 -799 ms + after CURRENT_TIMESTAMP(3) 2026-08-23 10:22:36.799 2026-08-23 10:22:36.802 +3 ms + ``` + + Nothing errors. Three silent consequences, in ascending order of damage: + + 1. **"Last modified" precedes "created".** Any consumer comparing the two — an + audit answer, a "modified since creation?" badge, a data-quality check — + reads a row that WAS modified as if it were not. + 2. **A delta / incremental sync SKIPS the row.** A cursor held at millisecond + precision (`updated_at > cursor`) misses every row whose stamp was truncated + back below it. Measured: all six rows in the new suite's §2 were invisible to + their own cursor immediately after being updated. This is the same + silent-wrong-answer family as #11067 / #11176 / #11223, reached by a fourth + mechanism. + 3. **Two updates in the same second are indistinguishable**, so an + `order by updated_at` over them is unstable exactly where it matters most. + + The fix is the expression #11176 had already derived and measured for the UPSERT + door: `now(3)` on MySQL, unchanged elsewhere. Every UPDATE door reads one helper + (`update`, `updateMany`, `rotatedUpdateById`), so all three move together. + + **Postgres and SQLite emit byte-identical SQL to before, and that is a + measurement rather than an assumption.** Postgres' `CURRENT_TIMESTAMP` is + `transaction_timestamp()` at microsecond precision against a `timestamptz` + column; SQLite's stamp is a JS ISO-8601 string that already carries millis. + Neither has anything to truncate. The new suite runs every cell on SQLite AND on + live Postgres AND on live MySQL, and its §5 pins which expression each dialect + gets — so a future "just add `(3)` everywhere" cannot satisfy the ordering + assertions while changing the SQL the other two dialects emit. In the baseline + run against the unfixed driver, the SQLite and Postgres cells were green (7/7 + each) and only the MySQL cell was red (6 of 7). + + **BREAKING**, narrowly, and the reason this is not a patch: the `protected` + `upsertUpdatedAtStamp()` that shipped in 17.2.0 with #11176 is **removed**. It + existed only to hold the precision-matched form for the upsert door without + changing the SQL every `update()` emits — a split that card made deliberately + because it had not measured the UPDATE door. This one measured it, so the pair + collapses back into the single `updatedAtStamp()`, which now carries the matched + precision for both doors. A subclass of `SqlDriver` that OVERRODE + `upsertUpdatedAtStamp()` would otherwise have kept compiling while silently + ceasing to be called, which is precisely the failure mode a release note has to + name out loud. Such a subclass should override `updatedAtStamp()` instead; the + two in-repo subclasses (`SqliteWasmDriver`, `TursoDriver`) override neither and + are unaffected. Nothing else was removed or renamed, no authored metadata + changes, and no public API moves — under this repo's launch-window convention + (breaking changes ship as `minor` while the stack versions in lockstep) `minor` + is the honest slot. + + Stored data is not rewritten. Rows updated before this change keep their + truncated `updated_at`; the ordering invariant holds from the next write onward. + + +- 107bb4b: driver-sql (MySQL): carry an over-long UNIQUE index on a hash-shadow column + + On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a + full-value UNIQUE index over a longer column is inexpressible — an OAuth access + token that is a multi-KB JWT cannot be made keyable by any declared bound. + Measured on live MySQL 8.0.46, 7 of 44 exported platform objects failed + `syncSchema` outright and landed registered with their declared uniqueness + absent (Postgres 16.13: 0 of 44). + + Such a UNIQUE index is now carried by a driver-owned `__hash` column — a + `STORED GENERATED` `VARBINARY(32)` holding the full, untruncated SHA-256 of the + key values — with the unique index on that column. Uniqueness is still enforced + over the whole value: distinct values sharing a long prefix are both accepted + (the property that ruled out prefix-unique indexes), NULLs stay distinct, and a + composite tuple containing NULL conflicts with nothing. + + The shadow is created only *after* the server refuses the direct index, so the + dialect divergence is selected by the error code rather than by a dialect check: + Postgres and SQLite are byte-identical to before. Non-unique indexes are + deliberately left refused — an index over a digest accelerates no lookup the + planner can reach. +- 0e5bea6: New operator-run command `os migrate multi-value-columns`: migrates a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — the `manual_column_type_change` drift `os migrate apply` reports and deliberately never reconciles for you (#11535, ruled C on #11700). Flags: `--apply` (default off), `--yes`/`-y`, `--force`, `--table ` (repeatable), `--database-url`, `--json`. **Dry-run contract: without `--apply` the command executes nothing at all** — it prints the exact statements and the database they would run against, opens no seam and issues no probe, and a run is verified to have left the column type and every row unchanged. `--apply` runs `@objectstack/driver-sql`'s own `manualJsonConversionSql` — newly re-exported from that package's index for this consumer, its only other change — i.e. the statement the drift finding itself prints (Postgres: one `ALTER … USING (CASE …)` with `json_build_array`; MySQL: the two row-shaping `UPDATE`s then `ALTER … MODIFY … json`), refuses to execute anything the finding does not contain verbatim, re-runs detection afterwards and exits non-zero if the finding has not cleared. SQLite is excluded — the stale column round-trips a real array there, so the finding is never raised. Rows corrupted before the column is migrated are out of scope, and the command is never invoked automatically: nothing on the boot path reaches it. +- c05b40b: Stop reading every PostgreSQL `Field.date` one day early on a process east of UTC + + On PostgreSQL a `Field.date` came back **one calendar day early** whenever the + Node process ran east of UTC — an app container on `TZ=Asia/Shanghai` served + `"apply_date": "2026-08-23"` for a row `psql` reads as `2026-08-24`. The stored + value was always right; the read corrupted it, so the wrong day was already in + the REST payload before anything rendered it. Worse than a display bug: an + `afterUpdate` hook copying a date into a child record persisted the shifted + value, writing the wrong day back into the database. + + `node-postgres` materialises OID 1082 (`date`) as a JS `Date` at **local** + midnight, and `SqlDriver#toDateOnly` reads a `Date` with **UTC** components. + East of UTC, local midnight is the previous day in UTC. Measured on PostgreSQL + 16, one stored row `2026-08-24`, only the process `TZ` changed: + + | process `TZ` | `pg` materialised | driver returned | + |---|---|---| + | `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | + | `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | + | `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | + + Fixed at the parser rather than the reader: the driver now registers a + connection-scoped type parser so `date` (OID 1082) and `date[]` (1182) arrive + as their `YYYY-MM-DD` wire text and never become a `Date` at all — the same + shape SQLite has always had, and the same shape MySQL already had via the + existing UTC connection pin. `timestamptz` is untouched: an instant is what a + `Date` is for, and `Field.datetime` depends on it. The parser is registered on + the connections this driver opens, never through the process-wide + `pg.types.setTypeParser`, so a host application's own `pg` clients keep stock + behaviour. + + Reading local components in `toDateOnly` instead was measured and rejected: + that helper is shared by the read, write and filter paths, and a caller's + `new Date('2026-08-24')` is UTC midnight — local components would report it as + `2026-08-23` west of UTC, i.e. the identical one-day error moved onto the write + and filter paths. `toDateOnly` now documents the UTC clock as its contract. + + **If you worked around this, you can undo the workaround.** Running the app + process with `TZ=UTC` is no longer a prerequisite for correct dates, and any + app-side "+1 day" compensation on a PostgreSQL date read must be removed — with + this release the driver returns the stored day, so a compensating shift now + overshoots. Rows that were *written* through the old skew (a hook that copied a + date it had just read) still hold the wrong day and need a data fix; nothing + here rewrites stored data. + + One behaviour change beyond the corrected day: on PostgreSQL a raw read + (`driver.execute(...)`, or knex used directly on this driver's connection) now + yields a `string` for a `date` column where it previously yielded a `Date`. + Values leaving `find()` / `findOne()` / `aggregate()` / `distinct()` were + already normalised to `YYYY-MM-DD` strings and keep that type — only the day + they name changes. + + Pinned by a process-zone matrix (`UTC`, `Asia/Shanghai`, `America/New_York`, + `Asia/Kolkata`) that asserts it contains an east-of-UTC cell before it believes + itself: the existing live-Postgres CI job runs at `TZ=America/New_York`, which + is west of UTC, where the pre-fix read names the right day — which is why this + was green in CI for as long as it was broken in production. +- a11c1a5: `signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + + - **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. + - **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). + - **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + + Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. +- dfebfc8: feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) + + **BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` + under the repo's launch-window convention for breaking changes — and a widening + in the same edit, so read both directions. + + Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative + 归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol + without being the PostgreSQL this driver emits DDL for, and the driver had no + opinion about any of them — it simply let knex compile whatever it compiles. + Measured on `origin/main` before the change, one `CREATE TABLE` per client: + + ``` + pg / pgnative / cockroachdb "body" text primary key inline + redshift "body" varchar(max) primary key in a separate ALTER TABLE + ``` + + So on Redshift the pre-ruling behaviour was not a failure — it was a table of a + different shape, built quietly, with the deployment finding out when it wrote + data into it. + + **Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches + schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, + `reconcileManagedSchema` — now gets an immediate + `UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` + (newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), + HTTP status `501`, and a message naming the client, every client the driver + DOES emit for, and the supported way to keep the database — manage its schema + out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws + before any statement is issued, so nothing is half-built. Connection, the + connect bound and the #11389 calendar-day parser are untouched: the boundary is + DDL only, drawn where behaviour was actually verified. + + **Recognised (widening).** `pgnative` is now a member of the Postgres emission + family — knex resolves it to the same `postgresql` dialect and the same query + compiler as `pg`, differing only in which npm binding carries the bytes. It was + previously in neither the emission set nor the wire table, so a `date` column + got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact + #11550 defect) and no calendar-day parser. It now behaves identically to `pg` + and carries the #11389 pin. + + **One source of truth.** The pair `cockroachdb, redshift` used to be + hand-written into the connect-timeout table and again into the wire table. It + is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend + the emission sets through it — as does the refusal, which reads the same set. + Adding a future pg-wire client is one edit, and the three answers cannot drift + apart. `mariadb` is explicitly out of the ruling's scope and keeps its third + state: neither recognised nor refused. + + +- 4045b95: fix(driver-sql): make the SQLite `Field.json` codec injective — one encoding across all three dialects (#12380) + + **BREAKING** storage-format change for `Field.json` columns on SQLite (and the + SQLite-backed `driver-turso` / `driver-sqlite-wasm`, which inherit this codec), + shipped as `minor` under the repo's launch-window convention for breaking + changes. Postgres and MySQL are **untouched** — this makes SQLite match what + they have always done. + + `formatInput` now `JSON.stringify`s every `Field.json` value on every dialect, + and `formatOutput` parses it back. That **deletes a dialect branch rather than + adding one**. + + ## What was wrong + + Measured 2026-08-26 through the driver boundary on live SQLite, live Postgres + 16.13 and live MySQL 8.0.46, with each stored cell read back through a separate + raw catalog query: **Postgres and MySQL were 17/17 faithful; SQLite was 13/17 + type-changed.** Three independent mechanisms, only two of them reversible: + + 1. **Read-side.** `formatOutput` `JSON.parse`s every string in a json column, so + a stored string whose *content* is valid JSON came back type-changed — + `'true'` → boolean, `'null'` → null, `'[]'` → array, `'{"a":1}'` → object. + 2. **Write-side.** The column is declared type `json`, which contains none of + `INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/`FLOA`/`DOUB`, so SQLite's affinity + rules fall through to **NUMERIC** and a bound number-like string was converted + to INTEGER/REAL *before storage*: `'123'`, `' 123 '`, `'0123'`, `'1e5'`, + `'1.0'`, `'-0'` were destroyed on disk. ⛔ Not reversible. + 3. **Native booleans.** `true` was stored as INTEGER 1 and read back as the + number `1` — `formatOutput`'s `booleanFields` pass is keyed to declared + `Field.boolean` *columns*, not to booleans inside a json payload. + + The contract decides which dialect is right, not strictness: `json`'s stored + contract is `z.unknown()` because *"openness is now an explicit decision, not an + accident of nobody checking"* (`packages/spec/src/data/field-value.zod.ts`). An + explicitly-open contract admits both `123` and `'123'` as legal values of one + field, so no driver may collapse them onto one representation. + + The live consumer is `sys_setting.value`, which is `Field.json`, and the settings + service persists verbatim and reads back with no re-coercion by declared type — + so the driver's answer is what the caller gets, on the dialect tenant + environments actually run. + + ## What changes on disk, and what does not + + The DDL is unchanged — the column is still declared `json`, so NUMERIC affinity + is still in force. The encoded form defeats it because a string's encoding + carries its quotes (`'123'` → `"123"`, which is not a numeric literal). Pinned + live rather than reasoned. + + For **new** writes the on-disk delta is exactly two classes: + + - **strings** are now quoted JSON text; + - **booleans** are now TEXT `true`/`false` instead of INTEGER `1`/`0`. + + Objects, arrays, `null` and **numbers** are byte-identical to before (`123` bound + as a number and `"123"` bound as text both land as INTEGER `123`). + + ⚠️ An out-of-band reader of a SQLite file — anything reading the table with its + own SQL rather than through this driver — now sees quoted JSON text where it saw + a bare value. + + ## The migration, and the limits of what it can recover + + `backfillCanonicalJsonEncoding` runs on `syncSchema`/`initObjects` for existing + tables, the same posture and shape as the `backfillCanonicalDatetimes` and + `backfillCanonicalTimes` storage-format migrations beside it: one `UPDATE` per + column, failures logged and swallowed, correctness never contingent on it having + run. It converts the **one on-disk class the pre-fix encoding left unambiguous** — + a TEXT cell that is not valid JSON, which nothing but a stored plain string could + have produced — into its quoted form. Idempotent by construction: the `WHERE` is + the exact complement of the `SET`'s output, so a converted row cannot match again + and re-running costs one scan and zero writes. + + ⛔ **It does not guess, because the rest cannot be guessed**, and two classes are + therefore left exactly as they are: + + - **INTEGER/REAL cells.** A number, a boolean, and a number-like string eaten by + NUMERIC affinity are the *same bytes* on disk — `123` the number and `'123'` + the string are one INTEGER `123`. No migration can know which was written. + - **TEXT cells that already parse.** A stored object `{"a":1}` and a stored + *string* `'{"a":1}'` were byte-identical before this change. Re-quoting them + would turn every legacy object and array into a string — corrupting the common + case to guess at the rare one. + + ⇒ Those rows read after this change exactly as they read before it. **The class + stops growing; it is not retroactively repaired.** Maintainer ruling 2026-08-26, + with that cost accepted explicitly. + + The migration changes **no read**: a legacy plain string reads back as that + string before it runs (via `formatOutput`'s parse fallback, kept for exactly this + reason and now documented as the pre-#12380 read-side repair) and after it runs. + It is a canonicalisation that makes the on-disk format uniform and injective + going forward, not a repair of something that reads wrong today. + + ## What upgraders may notice + + Values that were being **corrupted** now read back correctly. Code that adapted + to the corruption is what changes underneath: a boolean `Field.json` value that + read back as `1` now reads back as `true`, and a string whose content is valid + JSON now reads back as that string instead of the structure it looked like. + Filters are unaffected — every scalar comparison operator on a json column is + already refused by the driver (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), so no + predicate could have been keyed to the old stored text. + + +- c49afd0: driver-sql: a string field's declared `maxLength` now shapes the column it gets + + `createColumn` mapped the string family — `string` / `email` / `url` / `phone` / + `password` — with a bare `table.string(name)`, so every column took knex's + default width of 255 and the field's own `maxLength` was never read. A field + declaring a wider bound got a narrower column, and on a dialect that enforces + `varchar` length the write was refused: measured through the driver's own + `initObjects` on MySQL 8.0.46 and Postgres 16, a 300-character value written to + a `maxLength: 1024` column came back `ER_DATA_TOO_LONG` and `22001 value too + long for type character varying(255)` respectively. `schema-drift.ts` has always + treated `varchar(field.maxLength)` as the expected physical shape, so every such + column also reported permanent drift against a table the driver had just + created. + + **This changes emitted DDL for existing declarations.** A field declaring + `maxLength` now gets `varchar(maxLength)` in both directions — wider *and* + narrower than 255. Only newly created columns are affected: `createColumn` runs + on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, never on a column that already + holds rows, so nothing is truncated and no existing column is rewritten. + Narrowing a populated column remains what it was — the `narrow_varchar` drift + op, category `destructive`, behind `os migrate apply --allow-destructive`. + + A declared bound above 16383 characters (MySQL's utf8mb4 `varchar` ceiling) + makes the column `TEXT` rather than clamping it, since a clamp would reinstate + the same defect. Fields declaring no `maxLength`, or a malformed one, keep + `varchar(255)` exactly as before. `lookup` / `user`, `autonumber`, and the + catch-all branch are deliberately unchanged — none of them stores the value the + declared bound describes. + + Two matching corrections in `schema-drift.ts`, so the differ and the emitter + agree on which declarations count: a `maxLength` that is not a positive integer + is no longer read as a bound (`maxLength: 0` planned a destructive `varchar(0)` + ALTER), and a MySQL `TEXT` column is no longer diffed as a `varchar` 65535 wide + — MySQL reports `character_maximum_length` 65535 for `TEXT` where Postgres + reports NULL, so on MySQL alone every bounded unkeyed text column had been + reporting a permanent destructive `narrow_varchar` against itself. +- 1246b4c: fix(driver-sql): the varchar differ now expects what `createColumn` would actually emit, instead of a different rule (#12732) + + The managed-schema drift differ's varchar-length branch expected + `varchar(field.maxLength)` for any bounded field, over a **pre-existing** + column dialect `postgres`/`mysql` enforce. `SqlDriver.createColumn` does not + build that for every bounded field — and disagreed with the differ in two + measured directions: + + **An already-serving deployment stopped booting.** An UNKEYED, bounded + text-family field (`text` / `richtext` / `signature` / `markdown` / …) + reported `narrow_varchar` at severity `error`, category `destructive` — the + one category `runArtifactBootMigrationGate` refuses a boot for — demanding + the column be narrowed to a shape `createColumn` would never build: unkeyed, + it leaves the column TEXT (`keyableTextLength` returns `null` unkeyed). The + trigger was an ordinary, correct-looking edit: adding `maxLength: 50` to a + legacy `varchar(255)` text column. The divergence changed no behaviour at + all — the write seam already enforces the declared bound — so the refusal + was over nothing. + + **A `safe`, dev-auto-reconcilable finding planned DDL MySQL refuses + outright.** A base string-family field (`email` / `url` / `password` / …) + bounded past `SqlDriver.MAX_VARCHAR_CHARS` (16383) reported `widen_varchar` + at `warning`/`safe`, planning `ALTER … varchar(100000)` — `ERROR 1074 Column + length too big` on MySQL, while Postgres accepted it: the dialect-divergent + enforcement this package's conformance matrices exist to close. + `declaredVarcharLength` returns `null` above the ceiling for the same reason + `createColumn` never emits that DDL. + + This guard had already been patched at the call site three times for the + same defect class (#11431 for `multiple: true`; #11794/#11875 for genuine + TEXT columns) — each time by adding one more condition. This is that defect + arriving a fourth time, through a column spelled `varchar` because an older + release created it. Rather than a fourth patch, the branch now asks + `SqlDriver.varcharColumnChars(field, keyed)` — the emitter's own read-only + mirror of `createColumn`'s switch, already pinned against `columnInfo()` for + every `FieldType` — what width `createColumn` would actually build. `null` + means "the emitter would not make this a varchar," and the branch does not + fire. Keyedness (`indexedKeyColumns()`, #11374) is threaded from + `SqlDriver.detectTableDrift` into `diffManagedTable`, since a KEYED bounded + text-family field legitimately takes `varchar(maxLength)` — the fix + suppresses the false positive, not the branch itself; a keyed field over the + same shape still reports. + + Graded `minor` rather than `patch`, mirroring the sibling drift-op change for + #12121 in the opposite direction: an already-serving deployment that + currently fails to boot over Case A will boot after this upgrade, and a + `widen_varchar` currently eligible for dev auto-reconcile over Case B will no + longer be planned — both are user-visible behaviour changes for an existing + deployment (`os migrate plan`, `os migrate apply`'s counts, the boot-time + `[schema-drift]` warn), not merely an internal correctness detail. `diffManagedTable`'s exported args object gains two **optional** parameters + (`keyedColumns`, `varcharColumnChars`); omitting either keeps the pre-#12732 + behaviour unconditionally; this is additive to the object type and — unlike + #12121's `DriftOp` union member — does not add a case any consumer's + exhaustive switch must handle, so it is not itself a reason to grade higher + than `minor`. Nothing is removed, renamed, or newly rejected, so this is not + a breaking change. The category question (whether Case A's `destructive` + should become a report) is deliberately **not** addressed here: the fix + makes the false-positive stop firing entirely, so there is nothing left to + downgrade, and downgrading it as a separate act would be gate-weakening the + triage seat did not authorise. + +### Patch Changes + +- ef52884: `aggregate()` now answers an unresolvable column with the same refusal class as `find()` and `count()` instead of the generic `DATABASE_ERROR`/500 terminal — the #8790 refusal reaching the third read door (#11541). The dialect-named column is attributed to the clause the caller's own query names it in: a `groupBy` field or an aggregation `field` refuses with `INVALID_FIELD`/400 naming the column and the clause (the same code the protocol ingress gives this condition, #4254); a column named by neither clause is the WHERE, which answers #8790's `INVALID_FILTER`/400 refusal verbatim; a dialect wording that yields no column name keeps the #11455 terminal envelope unchanged, because no attribution is supportable there (#8931). Drivers extending `SqlDriver` (`driver-turso`'s embedded face, `driver-sqlite-wasm`) inherit the same answers. +- 178f90c: Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`. +- f6fa22c: `min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. + + FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). + + The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. +- 84de7e3: fix(driver-sql): name the storage a declaration on a builtin column name loses, instead of discarding it in silence (#12015) + + `initObjects` emits `id`, `created_at` and `updated_at` itself and then skips any + declared field colliding with one — `if (builtinColumns.has(name)) continue;`, with + no warning, no throw and no record anywhere that the author's declaration had been + dropped. Measured on live PostgreSQL 16.13: an object declaring + `id: { type: 'text' }` boots green and gets `id varchar(255)` — `table.string('id')`, + not TEXT. Measured here on SQLite: the same substitution, and a declared + `maxLength: 12` on that field binds nothing. The driver is right to own its primary + key and audit stamps; the defect was that it disagreed with the author in silence — + the declared-≠-enforced shape that bites hardest on AI-authored metadata, where the + mismatch surfaces much later as data behaving oddly. + + Every DDL path that drops such a declaration now says so, naming the field, the + object, the attributes that were lost and what the platform's column actually is: + + - **create** — `while creating table "…"`, said before the CREATE runs, so the + author hears it even when the CREATE goes on to fail for an unrelated reason; + - **ADD COLUMN diff** — `while syncing existing table "…"`; this path drops the + declaration for a different reason (the builtin is already in the table, so the + diff never proposes it), and it is the path a stock upgrade takes; + - **rotation shard** — `while syncing shard "…"`, covering both the shard-create and + shard-column-sync branches. + + A warning on one path with silence on the others just moves the trap, so each path + carries its own call and its own pin: a regression to a silent `continue` on one path + fails by name rather than being absorbed by a sibling. + + **Only the STORAGE half is reported, because only the storage half is lost.** A + declaration on a builtin column name still carries `label` (and the locales generated + from it), `readonly`, `searchable` and the ADR-0113 write contract in `required` — all + honoured on the platform's column exactly as on any other. So the diagnostic fires + only when the declaration asks for storage the platform's own column does not deliver + (a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a + `multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime', + defaultValue: 'NOW()' }` describes precisely what lands, and says nothing. + `id: { type: 'number' }` — an author expecting a numeric key — still fires. + `id: { type: 'text' }` does **not**: varchar(255) canonicalizes to the field type + `text`, so that declaration asks for precisely what the column delivers (#12131 — + the delivery table recorded the knex builder name `'string'` there at first, and + reported all 45 of the platform's own correct `id` declarations as disagreements). The storage/presentation split is one table + (`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key + added later is classified deliberately instead of defaulting into silence. + + **Grade: `patch`, and deliberately.** Nothing about the accept set moves — every + object that booted before still boots, the DDL emitted is byte-identical, no public + type or metadata key changes, and the only observable difference is a line in the log + for storage that was already being discarded. The platform still owns `id` / + `created_at` / `updated_at`: this changes what the driver **says**, never what it + **does**. +- 3bc2e38: fix(driver-sql): the builtin-column delivery table speaks the spec's field-type vocabulary, not knex's builder names (#12131) + + `BUILTIN_COLUMN_DELIVERY.id.type` recorded `'string'` — the **knex builder name** from + `table.string('id').primary()` — and `undeliveredStorageAttributes` compares that value + with `===` against a declaration's `type`, which is a spec `FieldType`. The two are + different vocabularies, and `'string'` is not a member of the one being compared: it is + absent from `FieldType`'s 49 options, `Field.string` is absent from the builder's keys, + and `FieldSchema` refuses `type: 'string'` outright. So **no declaration could ever + match it**, and the #12015 diagnostic reported every correct declaration on the + platform's own key as a disagreement. + + Measured on a stock boot of `@objectstack/platform-objects`: **45 warnings, one per + system object**, each saying `type: 'text' (the column is 'string')` about a + declaration that was right all along. `varchar` canonicalizes to the field type `text` + (`canonicalizeSqlType('varchar(255)') === 'text'`, `suggestFieldTypeForSqlType('varchar(255)') === 'text'`, + `isCompatible('varchar(255)', 'text') === true` — all pinned in `type-compat.test.ts`), + so `id: Field.text(...)` asks for exactly what the platform's column delivers. The + delivery table now records `text`, and the 45 lines go silent because they were false, + not because they were suppressed. + + `sys_migration.id`'s `maxLength: 128` was the one **honest** disagreement in that corpus + — the column is varchar(255) — and it is removed rather than widened to 255. It bound + nothing in any seam: the DDL discards a declared width on a builtin column name, and + `validateRecord` skips `id` by name on both the insert and the update path (it is also + `readonly`). Declaring a width that nothing enforces is the shape enforce-or-remove + exists to prevent, and the 44 sibling system objects declare none. + + The classification pin now holds **every** entry in the delivery table to + `FieldType.options`, so a builder name written there fails by name instead of surfacing + as a corpus of false warnings. The fixtures in both #12015 pin files were written + against the delivery table rather than against the source — `sys_presence.id` was spelled + `type: 'string'` in the "silent" cases, which is why they passed while the same + declaration as actually written warned. They now use the shapes as declared, and the + firing cases declare a type that genuinely disagrees. + + **Grade: `patch` for both, and deliberately.** No door moves and no DDL changes: the + platform still owns `id` / `created_at` / `updated_at`, the emitted column is + byte-identical, every object that booted before still boots, and `BUILTIN_COLUMN_DELIVERY` + is internal to the package (it is not re-exported from the package entry). The + `platform-objects` half removes one metadata key that was measured inert in every seam + that could read it. What changes is what the driver **says**. +- f9ffd01: `SqlDriver` now recognises knex's own **canonical** client spellings, so `client: 'postgres'` and `client: 'sqlite'` no longer silently lose every dialect-specific behaviour (#11550). `SqlDriverConfig` is `Knex.Config & {…}`, so every client name knex accepts was already declared valid, while `isPostgres` / `isSqlite` enforced two literals each — and `postgres` is the canonical name of the dialect whose registered aliases are `pg` and `postgresql`, `sqlite` likewise for `sqlite3`. Nothing failed on the unrecognised spellings; the driver just emitted the wrong SQL. The sharpest case: `nowColumnDefault` fell through to a bare `CURRENT_TIMESTAMP` default on a `DATE` column, which resolves the calendar day in the **server's** timezone — the exact defect ("a UTC-12 server records YESTERDAY") that method's Postgres branch exists to remove. The three getters, the connect-timeout table, the pg wire-protocol set and the MySQL UTC session pin now derive from one identity source per dialect family instead of four hand-written lists that had already drifted apart. What an already-recognised spelling (`pg`, `postgresql`, `sqlite3`, `better-sqlite3`, `mysql`, `mysql2`) resolves to is unchanged, and `redshift` / `cockroachdb` keep pg **wire** recognition without gaining SQL-**emission** identity — that remains an open support-scope decision (#11756). +- c804f0c: The stale multi-value column warning now names `os migrate multi-value-columns`, + instead of telling operators ObjectStack will never fix the column + + The finding that reports a multi-value field left on a stale `varchar`/`text` + column opened its remedy with **"ObjectStack will NOT change this column for + you. Migrate it by hand"** and then printed raw SQL. That was true when it was + written and became false the moment `os migrate multi-value-columns` shipped: + there is now an operator-run command that does exactly this, with a dry run as + the default, a confirmation prompt, and a post-run re-detection that exits + non-zero if the finding has not cleared. Operators were being sent to hand-write + DDL on a production table while the safer route sat one command away, unnamed. + + The message now leads with the command and keeps the hand-run statement after it + for anyone without the CLI. Both surfaces an operator meets this on pick the + change up, because both print `message` verbatim: the boot warning + (`[schema-drift] …` on every restart) and `os migrate plan`. + + What has **not** changed is what the finding gates. It stays `severity: 'error'`, + `category: 'needs_confirm'` — the artifact boot gate refuses a boot on + `category === 'destructive'` and on nothing else, and every database this finding + describes is already serving, so making the report louder must never be the thing + that stops one from starting. No load-time or write-time refusal was added; the + platform still never migrates the column on its own, per the ruling that it warns + and ships an explicit operator-run migration rather than altering a customer's + production table unattended. + + The dialect-specific statement stays embedded **verbatim**, which is a contract + rather than formatting: a `ManagedDriftEntry` carries no dialect, so the CLI + command recovers one by testing which dialect's statement the message contains. + That coupling is now pinned from the emitting side as well as the consuming one. +- 9d3c04d: fix(driver-sql): `aggregate()` joins the enveloped read exits — a dialect error it + cannot attribute now leaves as `DATABASE_ERROR` / 500 instead of raw (#11455) + + `SqlDriver.aggregate()` executed its statement **bare**. Every dialect error the + backend raised left the driver as the backend's own error object: a `code` from + the backend's vocabulary, **no `status`** at all, and a message opening with the + compiled statement. `find()` and `count()` have carried the terminal ADR-0112 + envelope since #8931; this third read door was simply never given it. + + Measured on live PostgreSQL 16.13. The driver maps a `boolean` field to a real PG + `boolean` column and `SQL_AGGREGATE_FUNCTIONS` lowers the arithmetic aggregates to + a bare function name with no cast, so an ordinary analytics shape — a rate measure + over a flag column — reached the server as `avg("flag")`: + + ``` + sum(flag) => THREW code=42883 status=undefined + msg=select sum("flag") as "n" from "…" - function sum(boolean) does not exist + ``` + + A raw `42883` is on no list `@objectstack/rest` reads, so with `status` undefined + a caller-shaped mistake was logged as an **unhandled server fault**, and the + statement's shape travelled to the caller with it. + + `aggregate()` now composes the same `backendStatementFaultError` its two siblings + do: `DATABASE_ERROR` / 500, asserting exactly one thing — *the backend would not + run this statement* — with the dialect's own diagnostic written to the **server + log** rather than the caller's message, and the original error kept as a + non-enumerable `cause` so `isMissingTableError` and every other cause-following + predicate stay truthful. + + **No new error code.** ADR-0112 D3/D4 closed the `StandardErrorCode` vocabulary, + and D2's 2026-08-18 amendment retired three members on the reasoning that an + unreachable-but-declared code teaches a branch that can never fire. The code here + is the catalogued member the sibling read exits already answer with. + + **This is the envelope half only, and it decides no contract.** Whether the + platform should *answer a number* for an arithmetic aggregate over a boolean (by + casting in the lowering) or *refuse* is #11152's question, and #11249's for + `min`/`max`. Nothing here pre-empts it: the envelope is raised from the **exit**, + not from recognising `42883` or any wording, so it holds whichever way that card + is ruled — and the three dialects' arithmetic answers are deliberately left + unpinned (measured 2026-08-24: SQLite and MySQL's `tinyint(1)` both answer, + Postgres refuses). + + Unchanged, and pinned as controls: the precise refusals this door already + composed — an undeclared function (`INVALID_QUERY` / 400, #5907), a + `count_distinct` with no `field` (`INVALID_QUERY` / 400, #6409), a + per-aggregation `filter` (`NOT_IMPLEMENTED` / 501, #10576) — are all raised while + the statement is *built*, upstream of the guarded execution, so none can be buried + under the generic envelope. The accept set does not move: every condition that now + takes the envelope failed before this change and fails after it. +- d29e42f: chore(driver-sql): pin the varchar-sizing type switch against the spec's `BOUNDED_STRING_FIELD_TYPES`, so the two lists cannot drift apart silently (#12017) + + `packages/spec` decides which field types may DECLARE a `maxLength` + (`BOUNDED_STRING_FIELD_TYPES`, which `FieldSchema` and objectql's + record-validator both read since #11989/#11875). `driver-sql`'s + `varcharColumnChars` / `createColumn` switch decides which types get a column + SIZED from that declaration. The two are related by reasoning and nothing + asserted the relationship — so a type admitted into the spec's set without a + matching hand edit to the driver's switch falls to the catch-all + `table.string(name)` at knex's varchar(255): the author declares + `maxLength: 2000`, the platform formally accepts the declaration, and the + column refuses at 255. That is #11431's defect re-entering through a different + door. #12119 is the proof it is reachable — admitting `signature`/`qrcode` + required a hand edit to this switch that nothing would have caught if it had + been forgotten. + + ⛔ No divergence existed: the lists were measured and agree. This adds the + missing guard, and changes no runtime code. + + The pin asserts set EQUALITY over the spec's `FieldType` vocabulary — the types + the switch sizes from a declared `maxLength` are exactly the types the spec + permits to declare one — and identifies each type's branch by probing the + driver's own dispatch, so no copy of either list is added. `'string'` is pinned + separately as the switch's untyped default (`field?.type || 'string'`, knex's + builder name, not a spec `FieldType`), which is why the equality is scoped to + the declared vocabulary. + + Grade: `patch`, argued rather than defaulted. Not `minor` — no new public API, + no widened accept-set, no behaviour change of any kind; the emitted DDL is + identical. Not `skip-changeset` either, though this PR ships only a test: the + package's published CONTRACT (a bounded-string field gets a column that honours + its declared bound) becomes a checked invariant here, and the CHANGELOG line is + the record a future reader needs when the guard goes red. +- fcd0efc: fix(driver-sql): scope the Postgres `introspectForeignKeys` catalog read to the session's own schemas (#11201) + + The Postgres arm queried `information_schema.table_constraints` with + `tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?` and **no `table_schema` + predicate at all**. Those views span every schema the session has privilege on, + independently of `search_path`, so a table name that exists in more than one schema had + all of their foreign keys merged into a single answer — including foreign keys from + schemas the session can never reach unqualified. + + That is a wrong answer rather than a missing one, and it is consumed as fact: + `introspectSchema` hangs the result on the table it just listed, and from there it reaches + federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift + comparison. A phantom foreign key makes a drafted federated object reference a table it + does not reference. + + The fix is the pin the rest of the family already carries — + `AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed exactly as + `introspectUniqueConstraints` spells it, which in turn follows `introspectSchema`'s own + table listing. `introspectForeignKeys` was the last unscoped introspection arm; the two + `pg_index`-based arms (`introspectIndexes`, `introspectPrimaryKeys`) reach the same scoping + from the other side by resolving the name to an OID through `regclass`. No interface shape + and no accepted input changes: a same-named table in another schema simply stops + contributing foreign keys it never should have contributed. + + Measured on a live PostgreSQL 16.13. The regression pin + (`sql-driver-11201-introspect-fk-schema-scope.test.ts`) builds the collision the repo's own + live-PG isolation (#9350, one schema per test file in one database) already makes routine: + two same-named tables in two schemas, each with a different foreign key. It first asserts + the pre-fix predicate really sees both constraints — so the interesting assertion, an + absence, cannot go green on a fixture that never collided — then requires the arm and + `introspectSchema` to return only the current schema's. Reverse-verified: with the + predicate reverted the pin fails with the neighbour's foreign key present in the answer. + + The MySQL arm of the same method was checked and is not affected: it already pins + `TABLE_SCHEMA = DATABASE()`. SQLite has no schemas. +- 3f42920: fix(driver-sql): keep the logger's receiver at the nine detach-then-call sites — a class-based host logger no longer turns a durability warning into a `TypeError` (#12792) + + Nine sites in `sql-driver.ts` picked a log channel by **extracting** the method before + calling it — eight on the durability channel and one on `info`: + + ```ts + (this.logger.error ?? this.logger.warn)(msg, meta); // 8 sites + (this.logger.info ?? this.logger.warn)(msg); // 1 site + ``` + + `a.b` in *call position* passes `a` as the receiver; `(a.b ?? c.d)(…)` evaluates to the + bare function first, so the call runs with `this === undefined`. A plain-closure logger + does not read `this` and survives it — which is why no suite ever went red, since this + class's own default sink and every test double in the package are closures. + `@objectstack/core`'s `ObjectLogger` is a real class with prototype methods and no + constructor binding — `error`/`fatal` reach for `this.writeErrorLike`, + `debug`/`info`/`warn` for `this.write` — so a host that injects one got: + + ``` + TypeError: Cannot read properties of undefined (reading 'writeErrorLike') + at error (packages/core/src/logger.ts:414:14) + at SqlDriver.syncDeclaredIndexes (packages/drivers/driver-sql/src/sql-driver.ts) + ``` + + The asymmetry that makes it worth fixing rather than noting: these particular lines + report **durability degradation and schema drift** — the channel that exists to be loud + when a constraint the metadata claims is enforced is not. A throw there converts the one + signal into silence plus an unrelated crash, and where the site sits inside the + reconcile's own `try` the throw is swallowed and re-reported as + `dev auto-reconcile failed` — a reconcile that really happened, announced as a failure, + with the post-reconcile re-detect skipped so the next warning describes a state that is + no longer true. + + The eight `error ?? warn` sites now call `logDurabilityFailure()` — the property-access + helper this class already had, three lines from the docblock that explains it. The ninth + is `info ?? warn` and reports a reconcile that **succeeded**, so it keeps its level with + an in-place property-access spelling rather than being escalated onto the durability + channel; escalating a functional report to `error` is the over-application AGENTS.md + names as what makes `error` unreadable in the first place. + + **Why `patch`.** No export, signature, accepted input or rejected input changes, and no + message text changes. A host whose logger is a plain closure object sees byte-identical + behaviour — that shape worked before and is pinned unchanged. The one behaviour a + consumer could observe is a subclass that overrides the `protected` + `logDurabilityFailure`: eight more calls now route through its override. That method is + already this class's declared verb for the durability channel and the fallback semantics + at those sites are unchanged (`error` when the sink has one, else `warn`), so more calls + honouring the override is the documented intent rather than a break. + + Also measured and recorded rather than assumed: **nothing composes an `ObjectLogger` + into `SqlDriver` today**. The plugin's `onEnable` builds `new SqlDriver(config)` and + never passes the kernel's logger, the constructor reads no `logger` key, and every + `driver.logger = …` assignment in the repo is a test or a testkit; the one production + seam that *can* install one is `SqliteWasmDriver`'s constructor, inherited straight into + this class, and no caller passes it yet. So these were latent, not live — which decides + urgency, not whether: a call that runs with `this === undefined` is a defect whatever + today's wiring happens to tolerate. + + The regression pin (`logger-receiver-detach.test.ts`) uses **class-based** logger doubles + whose channels dispatch through `this`, drives the real reconcile and the real + declared-index sync against real SQLite, and adds a structural AST scan over + `sql-driver.ts` for all four detach spellings — including the two a single-line regex + cannot see, which is how this file's count was twice taken as a floor. +- dd4113e: fix(driver-sql): order the MySQL `introspectForeignKeys` read by the key ordinal (#11379) + + `SqlDriver.introspectForeignKeys`' MySQL arm read `information_schema.KEY_COLUMN_USAGE` + with no `ORDER BY`. `ORDINAL_POSITION` is the key ordinal and was selected by neither the + projection nor an order clause, so the row order of a composite foreign key's columns was + whatever the query plan happened to yield. + + That order is load-bearing. `IntrospectedForeignKey` is a flat per-column record with no + ordinal field, so a composite key is expressed as **ordered sibling rows** — `(x, y) + references p (a, b)` is `x -> p.a` then `y -> p.b`, and there is nothing for a consumer to + recover the position from if the rows arrive permuted. The Postgres arm pins this with + `ORDER BY … k.ord`; the MySQL arm was leaving it to the optimizer. + + This is a determinism fix rather than the repair of a wrong answer, and the measurement is + what distinguishes the two. On MySQL 8.0.46, a foreign key declared out of column sequence + — `foreign key (second_col, first_col) references ooo_parent (pa, pb)` — came back in key + order through this predicate with no `ORDER BY` at all. But on the same server, in the + same session, over the same view, the sibling `introspectPrimaryKeys` predicate + (`CONSTRAINT_NAME = 'PRIMARY'`) returned an out-of-sequence primary key in **column** + order — `carrier_code` at ordinal 2 ahead of `shipment_id` at ordinal 1. `KEY_COLUMN_USAGE` + therefore does not preserve the ordinal for free on this server: which of the two orders + you get is decided by the `WHERE` clause, and nothing declared that. The foreign-key + predicate was on the lucky side of a choice nobody made. + + Consumers that read composite foreign keys through `introspectSchema` — federated-object + codegen, the persisted `external_catalog` (ADR-0015), and schema-drift comparison — now get + the declared key order from MySQL by construction rather than by plan choice. +- 992161b: fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202) + + `SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that + `introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms + disagreed about what that list meant. SQLite pushed a column only when the unique index + had exactly one column; the Postgres and MySQL arms returned **every member of every + composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres + claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not + make — while through SQLite it claimed neither. + + The divergence was latent rather than active until recently: the Postgres arm's query + selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method + carried until #11161 turned every execution into `[]`. Live Postgres had therefore never + once reported a unique constraint through this method. Repairing that query is what put + three dialects into conflict on live systems for the first time. + + Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to + **single-column uniqueness only**: a column is reported iff some unique constraint covers + that column and nothing else. A composite constraint's members are deliberately absent — + a per-column boolean is structurally unable to say "a and b are unique *together*", so + setting it on both members asserts something different and false. Representing composite + constraints is option B and waits for real demand; until it exists, an absent flag on a + composite member means "not single-column unique", never "no constraint". + + All three arms now normalise their rows to a `UniqueConstraintMember` and decide through + one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres + arm additionally selects `constraint_schema` and keys constraint identity on + `(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a + unique constraint after the table and column, so two same-named tables in two schemas + produce two different constraints under one name — keyed on the name alone they would + fuse into an apparent two-member constraint and drop a genuinely single-column unique + (the #11201 defect class, one method over). + + Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of + `PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports + `name: null`, which the arm used to push into a `string[]` as a literal `null` — it is + now discarded, while still counting toward the index's width so `(d, lower(e))` cannot + read as single-column; and the returned columns are de-duplicated, so a column carrying + both a `UNIQUE` clause and a hand-made unique index is named once. + + No interface shape and no accepted input changes, and `isUnique` is only ever *set* to + `true`, so a column that stops being flagged carries `undefined` exactly as an + unconstrained column always has. The one in-tree consumer is + `introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a + drafted field's `unique: true` — it is the direct beneficiary: composite members no + longer draft fields declaring a single-column uniqueness the database never enforced. + + Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the + live Postgres and MySQL cells are declared through the shared dialect matrix and run in + the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned + directly against each dialect's real row shape, so the Postgres and MySQL decision is + measurable without a provisioned server. Reverse-verified by ablation: with the width + filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the + end-to-end SQLite cell, and the `isUnique` fold. +- ebcc34e: fix(driver-sql): an unkeyable TEXT column whose field ALREADY declares a bound now names the real remedy (#12999) + + One message served two causes and was true of only one of them. + + `explainUnkeyableTextColumn` turns MySQL's `ER_BLOB_KEY_WITHOUT_LENGTH` / + `ER_TOO_LONG_KEY` index refusal into operator-readable advice. It rendered + every such refusal as *"the field declares no `maxLength` … declare + `maxLength` on the field(s)"*. That is correct at CREATE time. On the UPGRADE + path both halves are false: the additive sync adds columns and indexes and + deliberately never rewrites a column's type (#3728), so once a release adds a + bound to a previously unbounded keyed field (#12978 did exactly that for five + `sys_notification_*` objects), the field declares a perfectly usable + `maxLength` while the physical column is still TEXT. The index is refused + again on every boot and the message tells the operator to do the thing they + already did — in production, once per boot, which reads as the release that + shipped the fix being broken. + + **What changed.** A second branch, selected per column on a criterion that + needs both halves: the physical column is TEXT *and* `keyableTextLength` says + a fresh create would have emitted `varchar(n)` for the field's declared bound. + Both inputs were already in hand on the failure path — the `columnInfo()` read + this method already performs, and the driver's `managedObjectFields` + registration. That message names the column, the bound it already declares, + that re-declaring changes nothing, and the remedy that does apply: convert the + column to `varchar(n)` **by hand, with a backup taken first**, restating the + FULL column definition on MySQL — `MODIFY` does not repeat a `NOT NULL` and + silently drops a `DEFAULT` it does not restate — after which the next boot + creates the index. A composite key that mixes a stale column with a genuinely + unbounded one names both dispositions rather than sending the operator down + one route for both. + + **What deliberately did not change.** + + - The CREATE-path message is **byte-identical**, and is what a field that + really declares no usable bound still gets. A declared bound *wider* than a + utf8mb4 key part can hold (768 characters) is not a stale column either — a + fresh create emits TEXT for it too — so it keeps the CREATE message, whose + 768-character ceiling is the fact that operator needs. + - The refusal stays **loud and stays a failure**. The index genuinely was not + created and a declared uniqueness is genuinely unenforced; naming a better + remedy is not a reason to downgrade or silence that. + - The additive sync still does **not** rewrite the column itself. A widening + `ALTER … MODIFY` takes an exclusive metadata lock on the table, which makes + it a destructive, hard-to-roll-back action and a deliberate manual floor + rather than something a boot may decide to do. + + Diagnostic text only: no schema, DDL, wire or API surface moves. +- d395692: Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two + introspection declarations to the measured emitted types (#11122, maintainer + ruling 2026-08-23, option B — 「其他同意你的意见」). + + The spec's introspection contract (`schema-diff-service.ts`) declared + `indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted + it — a consumer typed against the promise read `undefined` with no compiler + complaint. It also declared `defaultValue?: string` while the in-tree SQL + driver passes `knex.columnInfo().defaultValue` through raw (measured on live + SQLite: `null` for a column with no default, dialect-quoted strings such as + `'abc'` otherwise; other producers report native values such as `true`). + + - `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: + an absent key means the producer did not read indexes; an empty array is a + positive claim the table HAS none. Producers that did not look must omit + the key rather than emit `[]`. Wiring the index read into + `introspectSchema()` is explicitly NOT part of this change. + - `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow + before use instead of trusting a string promise no producer kept. + - The SQL layer's extra `maxLength` fact (driver-sql / objectql + `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to + `number | string` — SQLite reports the string `"255"` where other dialects + report a number. + + With the spec now telling the truth, the deliberate `Omit` workarounds in + `@objectstack/driver-sql` and `@objectstack/objectql` (which carved + `defaultValue` and `indexes` out of the spec types to keep the divergence + visible) are retired: both packages' introspection types now extend the spec + contract directly. + + Consumers that read `table.indexes` must guard for absence (none exist + in-tree — the requirement was never honored, so today's readers would have + crashed on `undefined` anyway); consumers of `defaultValue` must narrow from + `unknown` before string operations. +- e40a28c: fix(driver-sql): a declared `Field.boolean` answers JSON booleans on MySQL's row-read doors (#11782) + + `formatOutput`'s boolean read coercion — and its per-column mirror + `readPresentationKind`, which `distinct()` and the aggregate group-key / + `min`/`max` tracking consume — was gated `isSqlite`-only. On MySQL the storage + is `tinyint(1)` and mysql2 hands back a JS number, so a declared boolean + answered `1`/`0` through `find()`, `distinct()` and aggregate group keys while + SQLite and Postgres answered `true`/`false` — and, after #11635 presented + aggregate `min`/`max` on every dialect, `max(flag) === true` and + `row.flag === 1` disagreed on the same column over the same MySQL connection. + + Measured on live MySQL 8.0.46 before the fix: `find().flag` → `1` (`typeof + number`), `distinct('flag')` → `[0, 1]`, aggregate group keys → `1`/`0`. The + boolean presentation now runs on the two dialects whose stored boolean is a + number (SQLite `INTEGER` 0/1, MySQL `tinyint(1)`); Postgres stores a real + `boolean` node-pg already parses, so it deliberately stays outside the gate and + its answers are byte-identical. A `NULL` boolean stays `null` on every door + (absence is not `false`), and declared `number`/`string` columns are untouched. +- 7e83932: MySQL's row-size refusal now names the declarations that caused it (#11565). MySQL charges every bounded column's DECLARED byte width against a per-row budget, independently of the per-column `varchar` ceiling — measured on 8.0.46 through this driver, 15 fields at `maxLength: 1024` create and 16 are refused — and its own error names no column and no declaration, about a table its author described entirely in metadata. Schema sync now translates `ER_TOO_BIG_ROWSIZE` at both the `CREATE TABLE` and `ALTER TABLE ADD COLUMN` sites into the same failure re-worded: every varchar column the object produces, widest first, with its emitted width and its byte cost at the schema's real bytes-per-character (read from the server, not assumed), plus the fields that reach the budget while declaring nothing — `lookup`, `user`, `auto_number` and the option types all take `varchar(255)`. InnoDB's separate per-page limit answers with the same code and is reported with the number the server quoted rather than 65535. Deliberately a translator and not a pre-flight: it speaks only after the server has refused, so it cannot refuse an object MySQL would have accepted. Nothing is refused that was accepted before, and no other dialect is touched. +- 431d2fb: Retiring a shadow-carried UNIQUE index no longer leaves its generated column behind forever (#13056). + + `isHashShadowColumn`'s docblock is why the orphan-COLUMN drift pass skips a #11627 hash shadow, and it stated what happens instead: the column "is then cleaned up by the index's own removal path, not by a blind column drop". There was no such path. `dropIndexIfExists` issues one statement family — `ALTER TABLE .. DROP CONSTRAINT`, `DROP INDEX IF EXISTS`, `ALTER TABLE .. DROP INDEX` — and never touches a column. So when metadata stopped declaring the index, `diffManagedIndexes` reported it as an orphan, `os migrate apply --allow-destructive` dropped it, and the `VARBINARY(32)` STORED generated column survived keyed by nothing, while the orphan-column pass declined to report it forever, exactly as designed. A STORED generated column is recomputed and written on every INSERT and on every UPDATE touching its source columns, so a table accumulating retired declarations paid for them permanently and silently. + + The `drop_index` op now collects that column after dropping the index. Ownership is established first, never assumed — in the shape of #13015's `foreign` guard, a column the driver has not proved is its own is left in place and named in a warning rather than dropped: a column of that name that is **not generated** may hold user data, and a column some **other index still keys** is not this orphan (that second read is what makes "index first, then column" a checked precondition rather than an ordering comment). An unreadable catalog degrades to leaving the column alone. + + **Why the cleanup hangs off the op and not off `dropIndexIfExists`,** which has two other callers. The discriminator is not *which caller* but *is this index name coming back*, and only the op knows. `recreate_index` drops in order to re-create under the same name, and its shadow must survive: #13015's `reusable` branch re-keys the survivor in place instead of rebuilding the table around a regenerated STORED column, and a cleanup in the shared helper would destroy exactly that survivor on every rebuild. `replace_unique_index`'s legacy-name drop cannot reach a shadow at all — #13015 already excludes `isHashShadowCarrier` from legacy detection, in `diffManagedIndexes`, saying it does so *because* that op drops the legacy name. Both are pinned in the negative direction, since they are what a later move of the drop into the shared helper would break and nothing else would notice. + + **Why `patch` and not `minor`.** Nothing new is authorable, no export is added (the collector is `protected`), and no input that was accepted is now rejected or vice versa. What an operator will observe that they did not before is a `DROP COLUMN` in the applied set of a migration they had already opted into: the `drop_index` op was already `category: 'destructive'` and already required `--allow-destructive`, so the opt-in is unchanged — the difference is that it now finishes the job it named instead of leaving half of it on the table. A `drop_index` that finds the index already gone is now reported as *applied* rather than skipped when it collects the leftover column, because the apply did rewrite the table. +- 80f1dcd: **Fix:** `introspectForeignKeys`' Postgres arm no longer drops a cross-schema foreign key, nor returns a composite one as a cartesian product (#11324). + + The arm joined three `information_schema` views, and the correlations were wrong in two independent ways. Both were measured on live PostgreSQL 16.13, against the query as it stood after #11201, so neither was caused by nor repaired by that change. + + **A foreign key whose target lived in another schema vanished.** The join carried `ccu.table_schema = tc.table_schema`, which demands parent and child sit in the same schema. For a FOREIGN KEY constraint, `constraint_column_usage` describes the *referenced* side — that is exactly why the projection aliases it `referenced_table` — so its `table_schema` is the **parent's**, not the constraint's. A cross-schema reference therefore contributed **zero rows**, and the table reported having no foreign keys at all. That is the #7332 failure mode through a different door and it has no `onFailure` to consult, because nothing failed: `[]` does not read downstream as "I could not see it", it reads as *this table has no foreign keys*, and federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift comparison all act on it. Cross-schema references are the normal shape for the federated remotes ADR-0015 points this driver at. + + **A composite foreign key came back as the cartesian product of its columns.** The `kcu` ↔ `ccu` join carried no ordinal correlation at all, so an N-column key yielded N x N rows pairing every child column with every parent column. Measured, a 2-column key `(x, y) references p (a, b)` returned **four** records — `x -> a`, `x -> b`, `y -> a`, `y -> b` — where the answer is `x -> a`, `y -> b`. Because `IntrospectedForeignKey` is a flat per-column record, the two phantom pairs are indistinguishable from the real ones to every consumer: a wrong-shaped answer that type-checks. + + **The whole query moves to `pg_constraint` rather than the join predicate being patched.** `constraint_column_usage` exposes no ordinal column at all — measured, its seven columns are the catalog/schema/name triples for the table and the constraint plus `column_name` — so the composite half has nothing to correlate on inside `information_schema`. The conservative half-fix was tried and measured: correlating `ccu` on `tc.constraint_schema`, the spelling `introspectUniqueConstraints` already carries, repairs the cross-schema case and leaves the composite case at four rows. `pg_constraint` carries both facts on one row — `conkey` and `confkey` are parallel `smallint[]`s in key order — so unnesting them *together* pairs child column with parent column by construction, and `unnest(...) WITH ORDINALITY` keeps the key position the old join threw away. That is the shape `introspectPrimaryKeys` already uses for `indkey` (#11101 / #11162), and dropping to the catalog matches what that arm and `introspectIndexes` already do. + + **No interface change.** `IntrospectedForeignKey` keeps its flat per-column shape and gains no ordinal field. A composite key is expressed as **ordered sibling rows** — contiguous, in declared key order, each pairing its own child column with its own parent column — which `ORDER BY con.conname, con.oid, k.ord` now pins and the type's docblock now states. Measured on a key declared out of column sequence, `foreign key (second_col, first_col)`, the result is key order rather than column order. An ordinal field was considered and rejected: it would let a wrong `ORDER BY` keep shipping wrong rows that merely *describe* their wrongness, where the pairing is a fact the query itself has to get right. + + Schema scoping is unchanged in meaning: `ns.nspname = ANY (current_schemas(false))` is #11201's `tc.table_schema = ANY (…)` expressed over the catalog, so a same-named table in a schema `search_path` never reaches still contributes nothing. An unknown table name still yields an empty list rather than a throw, so the #7332 `onFailure` contract is untouched. +- 6c6157a: `os migrate plan` / `apply` examine the object set the composed host DECLARED, and report the boundary when they cannot + + A composed host stack (#12938) registers its plugins for their DECLARATIONS: `init()` runs, `start()` is suppressed. The pass that hands every registered object to its driver — the one that fills the `managedObjectFields` map `detectManagedDrift()` diffs — lives in `ObjectQLPlugin.start()`, and a host that brings its own `ObjectQLPlugin` (under the framework's own plugin name, so the CLI's capability injector de-dups against it) DISPLACES the standalone one, since duplicate registration overwrites by name. The result was a boot where no `ObjectQLPlugin.start()` ran at all: every host plugin declared its objects, and not one reached a driver. + + Measured on ObjectStack Cloud's staging control plane: 36 host plugins composed, ~80 `sys_*` tables declared, **8** examined — all eight belonging to the single service that provisions its own tables from a `kernel:ready` hook rather than relying on that pass. Every consumer-visible signal was green, and `Physical schema is in sync with metadata` was one composed plugin away from printing over seventy unexamined tables. + + Two changes: + + - **The composed boot now drives that pass itself**, over the deferral it already armed: `engine.syncObjectSchema(name)` per declared object, which reaches `SqlDriver.initObjects` exactly as the suppressed `start()` would have. A plan still writes nothing — the deferral records the create-table work instead of running it. + - **`plan` / `apply` report what they could NOT examine.** `--json` payloads gain `composition.coverage` (`registeredObjects`, `examinedObjects`, `unexaminedObjects`, and per-reason counts: federated, unbound, on another datasource, on a driver without schema registration, refused). When `unexaminedObjects > 0`, the human output refuses the unqualified "in sync" line and says the plan is PARTIAL instead. A consumer gate asserting coverage should read `composition.coverage.unexaminedObjects` — `managedTables` alone cannot tell a small deployment apart from a mostly unexamined one. + + `@objectstack/driver-sql`: `initObjects` no longer calls `ensureDatabaseExists()` while DDL is deferred. It is the one line there that can write — `mkdir -p` for a sqlite parent directory, and on Postgres/MySQL a `SELECT 1` that CREATEs the database on `3D000` / `ER_BAD_DB_ERROR` — and under the deferral there is no DDL for a database to exist for. `flushDeferredSchemaDdl` clears the flag before re-entering, so the confirmed `os migrate apply` still ensures the database ahead of the first `CREATE TABLE`. + + A project with neither an `objectstack.config.*` nor a compiled artifact is unchanged: it composes nothing, carries no `composition` key, and diffs the same five data-stack tables it always did. +- 6757eb2: A `redshift` datasource now gets the 10s dialect connect-timeout bound instead of silently degrading to the 15s pool backstop (#11784). `SqlDriver` answers three separate questions about a knex `client` name from three separate tables, and `redshift` was a member of the wire-protocol one (`POSTGRES_WIRE_CLIENTS`, which #11389 put it in so it gets the calendar-day parser pin) while absent from `DIALECT_CONNECT_TIMEOUT`. It reaches the server through the `pg` driver — knex's `Client_Redshift` literally `extends Client_PG` — so it has `connectionTimeoutMillis` and would have obeyed it; it just never received it, and `withConnectBound` skipped the injection. Nothing errored and nothing was logged: the bound was simply 50% looser than the method's own docblock declares ("the effective bound" at 10s, with `pool.createTimeoutMillis` a "strictly looser backstop, reached only by a dialect that has no connect-timeout knob (SQLite) or ignores the one we set"). A `redshift` host is neither of those. The practical consequence is the framework#3769 failure shape — an endpoint that accepts the TCP connection and never completes the handshake makes every query WAIT rather than fail, and the wait was bounded 5s later than declared, with knex's inaccurate "the pool is probably full" wording instead of pg's `timeout expired`. A host that sets its own `connectionTimeoutMillis` or `pool.createTimeoutMillis` is still left alone. `redshift` gains **no** SQL-emission identity from this: the connect-timeout knob is a property of the npm driver doing the connecting, not of which DDL dialect gets compiled, so this is independent of the open support-scope decision (#11756). +- 1c66fe4: fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias `reference_to`, and refuse the key instead of honouring it (#11567) + + `SqlDriver.createColumn` emitted `table.foreign(name).references('id')` for a + relationship field carrying `reference_to`. `reference` is the only relationship + spelling `@objectstack/spec` declares — `reference_to` is a **rejected alias**, + answered by `FieldSchema` with `unrecognized_keys` and *"Did you mean + `reference_to` → `reference`?"* — so that branch could not fire for any + spec-conformant lookup, and never had. + + **This is not a behaviour change for any authored deployment.** Measured across + all 44 exported platform objects on live PostgreSQL 16.13 and MySQL 8.0.46 + before the change: **0** FOREIGN KEY constraints. `reference_to` has zero + non-test assignments repo-wide; the branch was reachable only by metadata that + went around Zod through raw `registerObject` (which deliberately skips it). + + What changes is that the driver no longer disagrees with the spec in silence. A + field still carrying `reference_to` at DDL time now throws + `VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same + words `FieldSchema` uses, rather than quietly changing the physical schema. One + key, one answer, on both doors. + + Fix, if you have such metadata — the same rename the schema has always asked for: + + | Wrote | Write instead | + |---|---| + | `{ type: 'lookup', reference_to: 'account' }` | `{ type: 'lookup', reference: 'account' }` | + + Referential integrity is unchanged and remains the **engine's**, applied via + `deleteBehavior` (the `409 DELETE_RESTRICTED`) — which is what + `content/docs/protocol/objectql/types.mdx` has documented since 2026-07-30. + + **Not graded as declared-breaking, deliberately.** ADR-0087's ledger reaches + upgraders about *authorable metadata* that must be rewritten. `reference_to` is + not authorable: the spec refuses it at the authoring door today and did before + this change, so no conformant object definition behaves differently and no + migration is owed to any deployment `objectstack migrate meta` can see. The + prescription above exists for metadata that bypassed validation, not for a + surface this repo ever published as writable. +- b826390: A `richtext` field now takes an unbounded TEXT column instead of knex's `varchar(255)`, so an ordinary rich-text body over 255 characters can be written (#11794). `createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`; `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`. Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16: before this change the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001 value too long for type character varying(255)`) while the same body in a `markdown` field on the same table was accepted; after it, the column reads back as `text` from `information_schema` on both and the value round-trips byte-identically. `code` moves with it for the same reason. + + Membership is now decided by a stated, measured test instead of the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the **write seam** enforces its declared `maxLength`, which is the invariant `schema-drift.ts` already rests on ("A TEXT column refuses nothing a `maxLength` allows … the bound is enforced at the write seam"). objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else, so both moved types keep a field-named ADR-0112 refusal for an over-declared value and the physical surface is restored to the declared contract rather than widened past it. The set of types that take an unbounded column when unkeyed is pinned as a whole, so the next addition has to be stated on purpose. + + `signature` and `qrcode` are **not** moved, deliberately and against the first reading of this defect. Their stored value is the author's own and routinely far past 255 characters (a data-URI PNG), so `varchar(255)` refuses ordinary values for them too — but the record-validator has no `max_length` branch for either, so an unbounded column would accept values a declared `maxLength` forbids: over-accepting in place of under-accepting, which is a physical surface wider than the contract. They stay bounded until the write seam can bound them, and the live-dialect suite asserts that refusal out loud rather than leaving it undocumented. + + The #11374 keyed-and-bounded rule applies to the two new members unchanged: a keyed, bounded `richtext` / `code` column is still emitted as `varchar(maxLength)` so a declared index can key it on MySQL, and a keyed but unbounded one still gets the named `explainUnkeyableTextColumn` refusal rather than a silently weaker constraint. Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty. +- cd13488: A healthy hash-shadow-carried UNIQUE is no longer reported as destructive index + drift — and the remedy that used to be proposed for it would have DROPPED the + constraint + + On MySQL a declared UNIQUE whose key is too wide for an InnoDB key part is + carried by a driver-owned generated column holding a SHA-256 of the key values + (#11627). The index differ compared the declared columns against the columns an + index physically KEYS, so a shadow-carried UNIQUE — one VARBINARY(32) generated + column as the whole key — could never match. A clean `initObjects` reported the + index the same boot had just created as `index_mismatch` / `destructive`, with + `recreate_index` as the remedy and `os migrate apply --allow-destructive` in the + message. + + Following that advice removed a live uniqueness guarantee. `recreate_index` + drops the UNIQUE by name and re-runs the additive sync; the sync retakes the + shadow route, and its `ALTER TABLE … ADD COLUMN` then failed on the generated + column that **survived** the index drop. That failure is a duplicate-COLUMN + error, matched by neither the "already exists" absorb (which spells index names) + nor the unique-violation branch — so the apply ended with the constraint dropped + and not re-created. + + Both halves are fixed, and they share one vocabulary rather than special-casing + the differ. The orphan-COLUMN pass already recognised the shadow as driver-owned + (`isHashShadowColumn`) while the index it carries was proposed for destructive + rebuild; that asymmetry was the shape of the defect. + + - The shadow's name derivation moved next to that predicate, so the name the + sync creates and the name the differ looks for have one definition. + - Introspection reads the shadow's stored `GENERATION_EXPRESSION` and records + the key it actually hashes, so the differ compares the key the constraint + **enforces** instead of the digest column it stores. Drift reports and plan + messages now name that key too, rather than `UNIQUE (uniq_…__hash)`. + - The sync inspects a surviving shadow column instead of assuming it absent: a + column already hashing the declared key is re-keyed in place, one hashing a + different key is re-generated, and a non-generated column of that name is + refused rather than dropped. + + Deliberately a real key comparison and not a blanket skip of every shadow. A + shadow written before #12998 hashes the RAW columns, so `CONCAT` yields NULL for + every NULL-organization row and the rows the `COALESCE(organization_id, + '__global__')` bucket exists to constrain are constrained by nothing (#5030's + shape) — indistinguishable by name from a healthy shadow. Skipping shadows + wholesale would have traded one false destructive finding for a true silent one; + that case is now reported as the ADR-0120 D4 tightening it is, runs the + duplicate pre-flight before anything is dropped, and is repaired by the apply. + A carrier whose expression cannot be read at all reports nothing rather than + proposing a drop it cannot reason about. +- df1c75c: MySQL hash-shadow UNIQUE indexes (#11627) now hash the DECLARED key: the NULL-safe organization key part of an org-scoped unique (ADR-0120 D3) is embedded as `COALESCE(organization_id, '__global__')` inside the generation expression, so NULL-organization rows fold into the global bucket and collide with each other — the same key the direct index would have enforced. Previously the shadow hashed the raw columns, `CONCAT` returned NULL for every NULL-organization row, and a shadow-carried org-scoped unique silently enforced nothing on exactly the rows (single-tenant stacks, admin-global defaults) the NULL-safe key exists to constrain, while the boot log reported the constraint as carried. Plain composite shadows are unchanged: any-NULL tuples still conflict with nothing, matching MySQL's own composite-UNIQUE semantics. + + Deployment note — turning this constraint on is data-dependent: a MySQL database that accumulated duplicate NULL-organization rows while the shadow enforced nothing will fail the shadow `ALTER` with `ER_DUP_ENTRY` on its next boot. That failure is now diagnosed, not fatal: the boot continues, the log names the conflicting groups (probed over the same COALESCE key) and the operator action (`os migrate plan`, deduplicate, re-run), and the constraint is honestly reported as NOT enforced until the data is deduplicated — the same disposition as the direct NULL-safe route (ADR-0120 D4). Write-path duplicate diagnosis follows the key: a genuine NULL-organization duplicate is named in declared terms instead of being misreported as a hash collision. +- 3956069: fix(driver-sql): correct two comments in `createColumn` that cited a `Field.string` builder that has never existed (#12593) + + Two comments in the keyed/bounded text-family branch of `createColumn` + (`signature` / `qrcode` / `richtext` / `code`) asserted a `Field.string` + builder exists and that it "has always taken knex's `varchar(255)`." It does + not exist and never has: `Field` has 37 keys and none is `string`, + `FieldType.options` (49 entries) does not list it, and + `FieldSchema.safeParse({ type: 'string', … })` fails at `[type]` — all three + reproduced fresh on this branch, plus `git log -S` confirming no commit ever + added such a builder key to `field.zod.ts`. + + The name is not arbitrary: `'string'` is knex's own column-builder method + name (`table.string(name)`), reused internally by this driver as its + *untyped* default (`field?.type || 'string'`) — a storage-side spelling that + collides with, but is not, an authoring-side one. The corrected comments now + state only checkable facts: knex's bare `table.string(name)` (no length) is + `varchar(255)`; this branch never calls it bare, it calls + `table.string(name, keyable)` with the field's own declared `maxLength` + (up to `MAX_KEYABLE_VARCHAR_CHARS`, 768 chars); and the nearest *authorable* + spelling that reaches this exact `varchar(n)` shape is `Field.text({ + maxLength: n })` on a keyed column — exactly what this switch arm already + serves. + + No code changed — `git diff` is comment-only lines inside `sql-driver.ts`. + Nothing here alters DDL, column widths, or any runtime behavior; the two + pins that already exercise this exact branch behaviorally + (`sql-driver-11565-row-byte-budget.test.ts`'s "agrees with createColumn about + every FieldType" mirror, and `sql-driver-keyed-text-mysql.test.ts`'s + "emits varchar(maxLength) for a keyed bounded field") both stay green, + unmodified. + + **Grade: `patch`, and deliberately no higher.** This is documentation + embedded in source, not an exported symbol, a spec key, or any authorable or + runtime surface — there is nothing here for a consumer to migrate. `patch` + is the correct floor for a fix that changes only what the driver's own + source *says*, matching the sibling `builtin-column-delivery-id-type.md` + changeset (#12131) that corrected the same false `Field.string` premise in + an adjacent file. Not a declared-breaking changeset, so no ADR-0087 + disposition marker applies. +- 5dd3bc9: **Fix:** on SQLite the builtin `created_at`/`updated_at` audit columns now take the same canonical ISO-8601 `DEFAULT` a declared `Field.datetime` NOW() column in the same table already gets (#11321). + + `createAuditTimestampColumn`'s non-MySQL branch was `table.timestamp(name).defaultTo(this.knex.fn.now())`. On SQLite `knex.fn.now()` compiles to an unqualified `CURRENT_TIMESTAMP`, which renders a zone-**naive**, space-separated, second-precision `'YYYY-MM-DD HH:MM:SS'`. A declared `defaultValue: 'NOW()'` field in the **same table** already got `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))` from `nowColumnDefault`, so one table carried two spellings of one conceptual value: + + ``` + created_at "2026-08-23 14:54:17" <- builtin audit (naive) + when "2026-08-23T14:54:17.796Z" <- declared field (canonical) + ``` + + That naive spelling is the one `updatedAtStamp()`'s own docblock condemns: `Date.parse` reads a zone-less string as LOCAL time, silently shifting the instant by the host offset on a non-UTC runtime. It is also the pre-canonical storage form `backfillCanonicalDatetimes` exists to converge — reached here by a path writing it *today*, not by legacy data. + + The SQLite branch is now routed through `nowColumnDefault('datetime')` — the existing single source for "what does NOW() mean in DDL on this dialect" — rather than restating the expression, so the two cannot drift apart again. **Postgres and MySQL are untouched**: `knex.fn.now()` on Postgres is a real zone-aware `TIMESTAMP` that never had the ambiguity, and MySQL keeps the `now(3)` precision match from #11224. + + `rebuildSqliteTablePatched` — the whole-table rebuild SQLite drift reconciliation uses — re-emitted the audit default itself as `knex.fn.now()`. That method is SQLite-only, so leaving it would have silently **reverted** a canonically-created table the moment any unrelated drift (a relaxed NOT NULL, an orphaned column) triggered a rebuild. Fixed in the same change: a rebuild hands back the column `initObjects` would have built. + + **Graded `patch`, not `minor`, on a measurement rather than a judgement.** The change alters emitted DDL, so the question that decides the grade is what it does to databases that already exist: + + - **Existing tables are not altered.** Both call sites are `CREATE TABLE` only; `initObjects`' `alterTable` branch adds declared fields and never the audit columns. A table already on disk keeps `default CURRENT_TIMESTAMP`. + - **They do not start reporting drift.** Measured on live in-memory SQLite through the real `detectManagedDrift` entry point against a table carrying the old default: **zero** entries. Two independent guards — `BUILTIN_COLUMNS` skips `created_at`/`updated_at` in both of `diffManagedTable`'s loops, and the only `default_mismatch` producer is the #4560 runtime-token check, for which `isAppResolvedDefaultToken('NOW()')` is pinned `false`. The measurement carries a positive control: in the same call on the same table, drift reports `unmapped_column` **and** `default_mismatch` for a `current_user` column, so the default-reading dimension is demonstrably live and still says nothing about the audit columns. + - **Rows already written naive keep reading correctly.** `formatOutput`'s `repairNaiveUtcAuditTimestamp` folds them to canonical on read — the same disposition `nowColumnDefault` already documents for declared fields. + + So no deployment changes behaviour on upgrade; only newly-created tables get the corrected default. + + The population this actually repairs is wider than "writes that bypass the driver". `stampInsertTimestamps` fills both columns app-side, but it gates on `tablesWithTimestamps`, which only DDL-running paths populate. On the documented `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` posture, `registerObjectMetadata` (the DDL-free registration door) deliberately does not touch that set — so the set is empty, the stamp returns early, and **the driver's own `create()` door reaches the column DEFAULT**. Measured, one table, one row per boot posture: `created_at "2026-08-23T14:54:17.791Z"` on a normal boot versus `"2026-08-23 14:54:17"` on a `skipSchemaSync` boot, with the declared NOW() sibling canonical in both — because its canonical shape lives in the column DEFAULT rather than in an app-side stamp. That asymmetry is the argument for fixing this in DDL, and it is now closed. +- 7adcd07: `introspectUniqueConstraints` no longer reports a PRIMARY KEY column as unique on SQLite, so all three dialects now answer the same question (#11654). The SQLite arm read `PRAGMA index_list` keyed only on `unique === 1`, and SQLite materialises a non-INTEGER primary key as a unique auto-index — so a `varchar` key was reported while the Postgres and MySQL arms, which filter on `CONSTRAINT_TYPE = 'UNIQUE'`, never see a primary key at all. It also disagreed with itself: an `INTEGER PRIMARY KEY` is a rowid alias with no auto-index, so the same logical schema produced a different `isUnique` flag depending only on the declared type of its key. The arm now skips `origin: 'pk'` index rows, which closes both gaps at once (`WITHOUT ROWID` keys included). + + This continues #11202's convention: `isUnique` means a *declared single-column UNIQUE constraint*. Nothing is lost — primary-key membership is still reported losslessly through `IntrospectedTable.primaryKeys` and `IntrospectedColumn.primaryKey`. The filter is on the index's `origin`, not on whether the column is in the key, so a key column that separately carries its own unique index stays flagged. + + Consumer-visible effect: `introspectedSchemaToObjects` in `@objectstack/objectql` turns this flag into a drafted field's `unique: true`, so a federated-object draft (ADR-0015) taken from a SQLite table no longer gains a redundant `unique: true` on its key column that the same table drafted through Postgres or MySQL never had. Drivers extending `SqlDriver` (`driver-turso`, `driver-sqlite-wasm`) inherit the change. +- f5a7f9c: **Fix:** on SQLite, `applyMigrationEntries` no longer reports an op as **applied** just because a table rebuild ran (#11722). + + `SqlDriver.applyMigrationEntries` splits by dialect, and the two arms disagreed about what `applied` means. The in-place arm (Postgres / MySQL) asks per entry and believes the answer — `applyDriftOpInPlace` returns `false` for an op its dialect cannot perform, and the entry goes to `skipped`. The SQLite arm did not ask at all: it called `rebuildSqliteTablePatched(table, ents)` and then `applied.push(...ents)`, every entry, unconditionally. But that rebuild honours exactly four op types — `relax_not_null`, `tighten_not_null`, `drop_column`, `drop_column_default` — and silently ignores everything else; its own docblock already said so for the varchar ops. An ignored op was still reported applied. + + **The failure mode is a false green, not an error.** Nothing throws and nothing is skipped, so every consumer announces work that never happened: `reconcileAndWarnDrift` logs `auto-reconciled on

    `, and the artifact boot gate prints `↪ migrated `. The finding is still physically present, so the next boot detects it again, reports drift again, and "migrates" it again — a loop with no failing signal anywhere in it. + + **What changes.** `rebuildSqliteTablePatched` now returns the entries it actually acted on, built in the same pass that fills the four column sets it already partitioned into — deliberately not a second list of op types to keep in sync, so the returned set cannot drift from the work done. The caller reports those as `applied` and routes the remainder to `skipped`, logging it in the **same sentence** the in-place arm uses for an op its dialect cannot do (` on
    . is unsupported on dialect 'sqlite' — skipped`), so one greppable line covers all three dialects. `@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso` extend `SqlDriver` without overriding either method, so both inherit the correction. + + **What deliberately does not change.** No op does anything different — this moves only what is *reported*. In particular the rebuild still runs for the whole table even when it honours nothing: it re-materializes every kept column's default (#11321, #4560) and the full declared index set from metadata (#3696), so it is not a no-op, and suppressing it would change what the reconciler DOES rather than what it says it did. `applied`/`skipped` remains a reported partition consumed by log lines and CLI counts; it is not an accept/reject door, and no public surface widens. + + **Latent when found, and fixed anyway.** The gap was unreachable at the time of the fix, held closed from two independent directions neither of which knew it was holding it: `enforcesVarcharLength` excludes SQLite, so the differ never emits `widen_varchar`/`narrow_varchar` there, and `multiValueColumnTypeIsLoadBearing` excludes SQLite for an unrelated measured reason, so #11535's `manual_column_type_change` is never emitted there either. The next column op that is not SQLite-rebuildable would have opened it silently. `manual_column_type_change`'s own docblock states that `applyMigrationEntries` reports it "skipped, never applied" — measured on Postgres and MySQL; that sentence is now also true on SQLite, and the docblock says so. + + Pinned by `packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts`, which constructs the reachability rather than waiting for it — it substitutes only the differ's dialect guard, handing entries straight to the public `applyMigrationEntries` seam that `os migrate apply` and the artifact boot gate both call, with a real driver, dialect and database throughout. All five cases fail on the pre-fix tree, including the consumer-level one that catches `auto-reconciled` being logged for an op that never happened. +- f24c90d: fix(driver-sql): route `updateMany()`'s payload through `formatInput` / `applyWriteColumnMap` (#11223) + + `updateMany()` was the only write door in `sql-driver.ts` that passed the caller's `data` + straight to `builder.update(data)`. Every other one — `create`, `update`, `bulkCreate`, + `upsert`, `rotatedUpdateById` — applies `applyWriteColumnMap(object, formatInput(object, data))` + first, and the WHERE side of the very same bulk statement was already being translated by + `applyFilters`. Measured on SQLite, live PostgreSQL 16.13 and live MySQL 8.0.46: + + - **`json` and `Field.multiple` values were refused.** Nothing stringified the structured + value for the bind, so each dialect refused it in its own voice: `22P02 invalid input + syntax for type json` on Postgres, `SQLite3 can only bind numbers, strings, bigints, + buffers, and null` on SQLite, and on MySQL the array expanded into the SET list itself + (``set `tags` = 'y', 'z'``) — a syntax error rather than a bind error. `update()` wrote + the identical values correctly in the same run. + - **A federated `external.columnMap` object's bulk update named a column that does not + exist.** The WHERE was mapped and the SET was not, in one statement: + ``update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'`` → `no such + column: name`. The door was unusable on every remapped external object. + - **Temporal values were stored verbatim**, silently. On SQLite a zone-naive + `'2026-05-06 07:08:09'` landed as-is — the pre-#3912 storage form + `needsLegacyDatetimeRepair` exists to repair on read, written into a column + `canonicalDatetimeFields` had already certified as canonical and therefore stopped + repairing. Measured end to end: a range filter over that calendar day returned only the + `update()`-written row, with the bulk-written row on disk carrying the right day and + invisible to the query. On live Postgres the same literal was resolved in the **server's** + timezone rather than UTC — `2026-05-06 07:08:09` stored as `2026-05-05T23:08:09.000Z`, a + silent 8-hour instant shift on an `Asia/Shanghai` server. `Field.date` and `Field.time` + were affected the same way: stored verbatim on SQLite, refused outright on the live + dialects. + + The literal `'NOW()'` token now resolves on this door as it does on every other one; it + previously stored the four-character string `"NOW()"` into a datetime column on SQLite and + was refused by MySQL. + + #11176's `updated_at` stamping is unchanged in effect — the stamping decision now reads the + formatted payload, matching `update()` and `rotatedUpdateById`, and the stamp is still + applied afterwards as the literal post-map column name. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index a3adffd24d..b6c89c50ff 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.2.0", + "version": "17.3.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 9e82cd6c4a..0ba6ac864f 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,212 @@ # @objectstack/driver-sqlite-wasm +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [178f90c] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [84de7e3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [f9ffd01] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [34d3011] +- Updated dependencies [9d3c04d] +- Updated dependencies [d29e42f] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [3f42920] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [ebcc34e] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [107bb4b] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [431d2fb] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [6c6157a] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [1c66fe4] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [cd13488] +- Updated dependencies [df1c75c] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [3956069] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [d028b37] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [1246b4c] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index 81d9a6b193..a2890926f1 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.2.0", + "version": "17.3.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 ab4029a234..354b7d36a2 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,271 @@ # @objectstack/driver-turso +## 17.3.0 + +### Patch Changes + +- 242eb0a: chore(driver-turso): declare and pin the `Field.json` column-type asymmetry between the local and remote transports (#12586) + + `TursoDriver` is dual-transport, and one declared `Field.json` becomes a + different physical column on each. Local/replica mode extends `SqlDriver` and + lets knex spell it (`table.json(name)` — a `json` column); remote mode never + touches knex and spells its own SQLite types in + `RemoteTransport.mapFieldTypeToSQL` (`TEXT`). Nothing in the tree said whether + that was a design or an oversight — a grep found the two `mapFieldTypeToSQL` + lines and one passing comment — and no test would have gone red if either side + moved. + + ⛔ Nothing is broken and no behaviour changes here. Both transports round-trip + every `VALUE_ROUNDTRIP_CASES` value faithfully today and did before this PR. + + **Why it is still worth recording.** Every column type in this driver is + spelled differently by the two halves — `varchar(255)`/`TEXT`, + `float`/`REAL`, `boolean`/`INTEGER` — and for all of those the difference is + cosmetic, because SQLite derives affinity from substrings of the declared type + name and both spellings land in the same class. `json` is the one that does + not: it matches none of SQLite's affinity markers, so it carries **NUMERIC** + affinity and converts number-like input on the way in, while `TEXT` converts + nothing. Measured on the shared fixture, that is not theoretical — a declared + `Field.json` holding the native `123` is an **INTEGER cell locally and a TEXT + cell remotely**, with `find()` answering `123` on both. Equal answers, unequal + bytes: the #11535 class in its quiet phase, where the next codec change has no + reason to be kind to both. PR #12585's ablation is the same fact in its loud + phase — the pre-#12380 `json` branch broke the two transports by *different* + counts, diverging on `s_0123`, because only the local column had NUMERIC + affinity to destroy a bare `'0123'` with. + + **What lands:** + + - The declaration, at the site a reader lands on when they ask why this returns + `TEXT` — `RemoteTransport.mapFieldTypeToSQL`'s doc comment: the full + local/remote type table, which rows are cosmetic and which one is not, the + affinity mechanism as the "why it is safe today", and the instruction to + delete or invert the pin rather than patch it green. + - The pin, `turso-json-column-type-asymmetry.test.ts`, driven by the same + `VALUE_ROUNDTRIP_FIELDS` / `VALUE_ROUNDTRIP_CASES` table the round-trip + conformance suite uses. It asserts each transport's declared types from the + **catalog**, demonstrates the affinity mechanism with raw SQL that bypasses + the driver codec, and asserts that the set of cases whose on-disk storage + class differs is exactly `{n_int, n_real}` — so convergence (an empty set) is + as red as one side drifting (a longer one). + - A note in `turso-value-roundtrip-conformance.test.ts` saying it is + deliberately blind to this, since it is green either way. + + ⛔ Convergence (making both transports emit one type) is **not** done here. + It changes what new columns are physically declared as and needs the + un-measured "why did remote choose `TEXT`?" answered first; #12586 ruled it a + separate decision. + + Grade: `patch`, argued rather than defaulted. Not `minor` — no new public API, + no widened accept set, no behaviour change, and the emitted DDL is byte-for-byte + what it was. Not `skip-changeset` either, though the only executable code this + PR ships is a test: what becomes a checked invariant here is a property of the + published package (which physical column a declared field gets on each + transport), and the CHANGELOG line is the record a future reader needs when + this guard goes red on them. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [178f90c] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [84de7e3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [f9ffd01] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [34d3011] +- Updated dependencies [9d3c04d] +- Updated dependencies [d29e42f] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [3f42920] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [ebcc34e] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [107bb4b] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [431d2fb] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [6c6157a] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [1c66fe4] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [cd13488] +- Updated dependencies [df1c75c] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [3956069] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [d028b37] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [1246b4c] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 353a2902a9..e504ea9c57 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.2.0", + "version": "17.3.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 582e8cd44b..e1416caf3a 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/formula +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 418d169cb0..b51f0efeb9 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.2.0", + "version": "17.3.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 8b847f2169..3145c34e59 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,407 @@ # @objectstack/lint +## 17.3.0 + +### Minor Changes + +- 09ae32e: Judge `visibleWhen` / `readonlyWhen` / `requiredWhen` examples in the docs corpus + as CEL — where the enclosing structure says which layer they are about + + `{/* os:check */}` blocks are type-checked by `tsc --noEmit`, and every CEL + string is the same type as every other CEL string, so + `visibleWhen: "record.status != 'closed' && user.hasRole('admin')"` type-checked + perfectly. `hasRole` is a CEL function that exists nowhere — it is in no stdlib + registry and on no contract — so the predicate faults at runtime, and a + field-level `visibleWhen` fault is fail-**open**: `resolveFieldRuleState` + evaluates visibility with `fallback: true`, so the element the author wrote the + predicate to hide is shown to everyone who copies the page. That is not a + hypothetical shape — a shipped doc taught it (#11034 fixed the instance). + + `check:doc-formula-expressions` gains this as a third scan surface rather than a + second gate, because two gates with opinions about one contract is the thing + Prime Directive #12 exists to prevent. The verdict is imported whole: syntax, the + unknown-function catch and the bare-reference rule come from + `@objectstack/formula`'s `validateExpression`, and the closed-root rule comes + from `fieldRuleRootIssue` — the same two the metadata walk applies to the same + slot, in the same order, in the same words. + + **The layer is decided first, and a layer that cannot be decided is skipped and + printed.** `visibleWhen` is one key spelling several unrelated contracts, and the + binding root really does differ: an object field binds `record` + `previous` + (+ `parent`), a per-option predicate binds `record` plus the host predicate scope + including `current_user`, a page component binds the user roots and `app`, and a + flow-screen field **flattens its own field names to top level**. A gate keyed on + the key alone would have gone red on + `content/docs/automation/flows.mdx`'s correct `visibleWhen: + 'createOpportunity == true'` and on `content/docs/ui/pages.mdx`'s correct + `'sales_manager' in current_user.positions` — and a gate whose reds are wrong is + worse than no gate, because it teaches people to add ignores. + + So admission is structural and schema-backed, never keyed on the key: a + `Field.*({ … })` factory call, or a raw field definition carrying `type:` inside + an object-literal `fields:` **map**. The map-versus-array test is the load-bearing + half and it is read off the schemas — `ObjectSchema.fields` is + `z.record(name, FieldSchema)` while `FormFieldSchema` and `ScreenFieldConfigSchema` + are both `z.array(…)`, so a `fields:` map is the object-field layer and nothing + else, and a `fields:` array is exactly the case that cannot be told apart. + + **The skip list is printed and counted on every run, including green ones.** A + gate that skips in silence is the same false-green one level up, so the summary + names every skipped site and why. Measured on the corpus as it stands: 23 + text-level `*When:` occurrences, of which 13 are admitted and judged, 7 are + listed as skipped, and 3 are the ADR quoting `field.zod.ts`'s schema + (`visibleWhen: ExpressionInputSchema.optional()`) rather than authoring a + predicate. Three of those seven were invisible to an AST-only walk — a bare + `visibleWhen: "…"` line at statement position is a labelled statement, not a + property — so a text-level tripwire reconciles the two counts and any site the + parser never surfaced is listed rather than dropped. + + `@objectstack/lint` newly exports `fieldRuleRootIssue` and + `FIELD_RULE_BOUND_ROOTS`. The field-rule root decision was a closure inside + `validateStackExpressions` — correct while it had one caller, and exactly how a + second caller comes to own a dialect of a rule instead of the rule. Behaviour is + unchanged: the metadata walk now calls the extracted function and its 2271 tests + pass untouched. +- 12e306a: The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. +- 225e769: Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap. + + `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, and the open string arm is deliberate: custom and registered components (`object-grid`, `mcp:connect-agent`, `custom.widget`, kebab SDUI blocks) keep parsing exactly as before — nothing about the parse changed. What is new is that the spec now answers for its own namespaces (`page:` `record:` `nav:` `global:` `user:` `ai:` `app:` `element:`, derived from the enum): a type inside them that the vocabulary does not declare is refused at author time by the new gating rule `component-type-unknown` (`os validate` / `os build` / `os lint`), with the closest declared spellings suggested. Previously `global:serch` validated clean and the published page drew a literal "Component Placeholder" scaffold in front of the end user. + + - `@objectstack/spec` exports the vocabulary claim from `@objectstack/spec/ui`: `RESERVED_COMPONENT_TYPE_NAMESPACES` (derived), `KNOWN_COMPONENT_TYPES` / `KNOWN_COMPONENT_TYPE_CANDIDATES`, `STRING_ARM_REGISTERED_TYPES` (the evidenced ledger of registered-but-row-less types, currently `record:line_items`), and the `hasReservedComponentNamespace` / `isKnownComponentType` predicates. + - `@objectstack/lint` ships `validateComponentTypes` (rule id `component-type-unknown`, severity `error`) on all three CLI commands; the runtime publish door is deliberately deferred pending a measured false-refusal budget over stored tenant page rows. + + If a page authored a type in a reserved namespace that nothing declares, the fix is the rule's own hint: rename to the suggested declared type, or move a genuinely custom component to its own namespace (e.g. `my-plugin:widget`) so it cannot be mistaken for platform vocabulary. +- 5383fa6: React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying ``'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `` / `` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window. + +### Patch Changes + +- 7345308: fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249) + + The whole `element:form` element retired at element grain (ADR-0049 — no + renderer ever shipped for it; the #9220 shape one element over), so every + `ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant + page carries `fields` on it. The field-binding rule's job (resolve a field + NAME against the object) is not the question a retired key raises: an authored + key is already reported by name with the element-retirement prescription — + which names the live replacement, the object-bound `object-form` block — + through the #5068 props gate, and the binding entry would only add a second + finding about a key that no longer exists — the #5775/#6629 residue class the + package's own `component-field-specs-liveness` gate refuses. +- f887e52: Re-measure four stale `current_user` binding-text sites, including the form SECTION slot + + The claim that `current_user` is unbound on a form-view **section** predicate was true when + it was written and is not any more: the console form renderer threads the host shell's + predicate scope into `isSectionVisible` (objectui#6110), and the object-view chain now + carries an authored `section.visibleWhen` through to an evaluator via the `section-divider` + pseudo-field (objectui#6111). Text only — no schema, no verdict and no runtime behaviour + moves. + + - `FormSectionSchema.visibleWhen` (`ui/view.zod.ts`) — the JSDoc and `describe()` now say the + root resolves, carrying the two qualifications the field-slot text already carried: the + binding is **client-side only** (no write-path evaluator reads a form-view section or field + `visibleWhen` — the rule validator's list is field `readonlyWhen` / `requiredWhen` and + per-option `visibleWhen`), and the scope is **empty on the public `/f/:slug` route**, which + is mounted outside any provider on purpose. The `features.*` refusal sentence is unchanged: + that root is unbound on both standalone form routes. + - `SelectOptionSchema.visibleWhen` (`data/field.zod.ts`) and + `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`) — the retired + exclusivity claim ("the one `*When` surface where `current_user` resolves") is trimmed. The + durable grounding stays and is now what the prescription rests on: per-option is the one + visibility predicate the **server** enforces, so the rule validator refuses a write of a + value whose predicate is false. + - `@objectstack/lint`'s field-rule message — the `visibleWhen` consequence clause is + re-measured. Under a scope-publishing host the predicate no longer faults: it resolves, the + control is hidden client-side, and the server still returns the value to every other reader + — a silent enforcement gap. The fault-open leg survives wherever no host publishes a scope. + The verdict is unchanged and the message says why it is now *more* justified: trading a loud + lint error for a gap nobody can see is worse than the error. +- 68c5dba: fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624) + + `os lint` computes i18n coverage and runs the authoring-rule registry in a + single pass over the same stack, and for the `flows` translation group the two + halves pointed opposite ways: + + | the author does | which rule fires | what it says | + |---|---|---| + | omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X | + | adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it | + + Measured on one stack, one run: omitting produced **4** `i18n/missing-flow` + findings and 0 liveness findings; authoring produced 0 demands and **2** + `liveness-planned-property` findings ("sets `flows` but this translation + property is planned"). There is no per-rule suppression in `os lint`, only + `--skip-i18n`, which silences the entire `i18n/missing-*` family — so the + author's only escape cost them every other coverage signal. Under + `--i18n-strict` the demand side is an **error**, so a project could be forced + to author keys it is then warned for. + + ⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner + reads the group, so a translated wizard string is stored and never shown — the + failure mode `validationMessages` was removed in 17.0.0 for. The premature half + is the demand. + + **The fix.** `collectExpectedEntries` — the single definition of what is + translatable at all, shared by the coverage gate and the `os i18n extract` + skeleton — now leaves out any translation group the liveness ledger warns + authors for authoring. It reads that set from `@objectstack/lint`'s new + `authorWarnedProperties(type)`, which returns the very warn-map + `lintLivenessProperties` iterates, so the demand side and the warn side cannot + drift into disagreeing about the same keys again. + + Two properties fall out of reading the ledger rather than switching on `flows` + by name: the bucket **turns itself back on** the day an objectui screen-flow + runner lands and the row flips to `live` (no flag, no follow-up edit), and any + future group that acquires an `authorWarn` is covered on the day it is marked + rather than re-opening this collision one group at a time. Today `flows` is the + only such group — pinned as an equality so a second one goes red instead of + shipping. + + No other bucket changes: `objects`, `apps`, `pages`, `dashboards`, + `globalActions` and `metadataForms` are all `live` and are reported exactly as + before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` + + `authorWarn: true`. +- 365e334: **Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288). + + `stack.translations` is `z.array(TranslationBundleSchema)` — each item is a `TranslationBundle`, i.e. `z.record(LocaleSchema, TranslationDataSchema)`, whose top-level keys are locale codes. The lint registered `{ type: 'translation', key: 'translations' }` in `TYPE_COLLECTIONS` and then walked those items flat, the way every other collection there is walked: `checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. A bundle has no `flows` key at any depth reachable that way — the groups live one level down, under each locale — so every warned lookup missed and the whole `translation` ledger was silent for file-authored bundles, the only way apps author translations today. + + That is the failure mode the comment above `TYPE_COLLECTIONS` names ("a newly governed type needs its collection registered or its ledger warns nobody"), reached from the other side: the collection *was* registered, and the shape underneath it was the mismatch. Registering a collection is only half the contract — the walk has to match the collection's shape — so the row is now a tombstone comment saying exactly that, and `translation` joins `object`/`field` as a bespoke walk: for each bundle, each locale entry's `TranslationData` is checked, with the finding subject naming the bundle index and the locale (`translation bundle #0 · locale 'zh-CN'`). + + Measured on a real app before the fix, as a guarded ablation: injecting a `flows:` section into a locale bundle and re-running `objectstack lint --json` produced **zero** delta — 91 issues before and after, 0 liveness findings naming `flows`. The author who reached for a `planned` translation group got silence, which is strictly worse than the ledger being absent, because the ledger's stated contract is that `authorWarn` is what tells them. + + Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings. + + The regression test is pinned on the **bundle** shape, and a `TranslationItem`-shaped anti-fixture is pinned alongside it. That shape — `locale` plus the groups at the top level — is the runtime metadata door, and it *warned on the broken walk*, so a fixture written that way would have been green from the day the bug shipped and pinned nothing. Runtime-authored `translation` items are reached by this lint through no door at all: no stack collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does not run at the runtime publish gate either. The two doors share the group vocabulary, not the container; only the file-authored one is lintable, and now it is linted. +- 20a452e: `lintLivenessProperties` now honours its own docblock contract ("Advisory only + — returns findings, never throws") when a collection item is `null` or + otherwise not an object. The object walk, the field walk nested under it, and + the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow, + action, agent, tool, …) each read `item.name`/`item.object` straight off every + element with no record guard, throwing `TypeError: Cannot read properties of + null (reading 'name')` on a malformed item instead of skipping it — reachable + via the exported `stack: AnyRec` signature on an unparsed or hand-built stack. + The translation bundle walk already guarded its two levels (#11383); this + closes the same hole on the three walks that did not (#11385). +- ba8420b: `lintLivenessProperties` no longer tells authors a `planned` property is `dead` + + `describe()` in `lint-liveness-properties.ts` only knew two verdicts + (`experimental`, everything else → `dead`), while the liveness ledger ships a + third: `status: 'planned'` (declared, and a consumer is being built against + it — contract-first, the opposite of `dead`). Every `planned` row fell through + into the `dead` branch, so the finding's own **message** told the author to + remove metadata the platform had asked them to write, while the same finding's + **hint** (when the row carried one) said the opposite one sentence later. Three + shipped rows hit this: `field.relatedListFilter`, `object.externalSharingModel`, + `translation.flows`. + + `describe()` now has a third branch: `status === 'planned'` gets its own rule + id (`liveness-planned-property`, mirroring `liveness-dead-property` / + `liveness-experimental-property`'s advisory-only posture — nothing downstream + keys off these ids today) and its own message/default hint ("keep it — a + consumer is being built against this property", never "Remove it"). + + The ledger's `status` field is a documented vocabulary, not a Zod-enforced + enum — nothing rejects a ledger entry with an unrecognised status. `describe()` + previously graded any such entry `dead` silently; it now throws, naming the + offending status, so a ledger-authoring mistake (a typo, or a new status added + without teaching this file about it) fails loudly at test time instead of + mislabelling a finding. +- b5a2398: Correct three stale `current_user` binding claims about a form FIELD `visibleWhen` + + A runtime form field's `visibleWhen` has resolved `current_user` — and the ADR-0068 D1 aliases `user` / `ctx.user` / `os.user` — since objectui#6010, but three texts shipped by these two packages still told authors the root was unbound there, and that per-option `visibleWhen` was "the only `*When` surface where it resolves". + + - `@objectstack/spec`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` now state the binding together with the two limits it does not remove — it is a rendering rule that nothing on the write path evaluates, so a role test written there protects no data; and the scope belongs to the host, so it is empty on the console's public standalone form route, where the predicate faults and visibility fails open. The generated `content/docs/references/ui/view.mdx` rows follow from the `describe()`. + - `@objectstack/lint`: the field-rule prescription no longer grounds "move it to the option's own `visibleWhen`" on exclusivity. It grounds it on enforcement — the rule validator evaluates a per-option predicate on every write — and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule, since moving one there would trade a loud lint error for a silent enforcement gap. + + No schema, validation or verdict change: the set of accepted metadata is byte-identical, and the rule still refuses a user root on an object field-level `*When`. +- f213793: `validate-translation-references` now checks the `flows` group — an authored key naming a + flow, screen node or screen field that does not exist warns instead of resolving to nothing + + The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised + top-level namespace is skipped and never reported, and `flows` was one of them. So a + bundle keyed to `flows..screens..fields.` parsed, shipped, and + silently resolved to nothing — the wizard rendering its source-locale string while every + other label on the screen was translated, which is the exact failure this rule exists for, + one namespace over. + + All three levels are exact-match identifiers with an enumerable universe, so the leg + mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen → + `FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are + `warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert, + not broken), and each names the declared universe it resolved against. + + Two shape facts the collector respects, both measured against the schemas rather than + assumed — either one read the obvious way would have made the leg a false-positive + generator: + + - **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`, + `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the + runner pauses on, so the universe is collected through `walkFlowNodes` rather than the + flat `flow.nodes`. + - **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen + (`config.objectName`) renders that object's own create/edit form and declares no + `config.fields`; its input labels resolve through `objects..fields.*`, so a + field key there is reported with that redirect rather than a bare "not declared". + + A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type, + not as a missing node. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [2a5c1cd] +- Updated dependencies [34f60b7] +- Updated dependencies [8beb3de] +- Updated dependencies [4a9f461] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/sdui-parser@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index e8392e7408..41e70e6f33 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.2.0", + "version": "17.3.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 881eab9ac1..40b4190b4a 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/plugin-mcp-server +## 17.3.0 + +### Patch Changes + +- 4ceae8a: MCP stdio transport now serves the workspace's CONFIGURED timezone/locale + instead of the manifest defaults + + The stdio transport resolved its localization inside `MCPServerPlugin.start()`. + `SettingsServicePlugin` registers its service in `init()` but binds its data + engine from a `kernel:ready` hook registered in its own `start()`, and every + plugin's `start()` body runs strictly before the first `kernel:ready` handler — + so that read was inside the settings bind window under **every** composition + order. Being ordered after the settings plugin did not help, and the + `optionalDependencies` edge that repairs the neighbouring ordering defects would + not have moved it either. + + In that window the read does not fail: the empty in-memory fallback plus the + manifest defaults answer with `source: 'default'`, so `resolveLocalizationContext` + returned `UTC` / `en-US` and reported success, never reaching its direct + `sys_setting` fallback. The value is then held for the life of the transport by + design, so a long-lived stdio MCP server served every call with `UTC` / `en-US` + on a workspace whose persisted `localization` settings said otherwise, and never + self-corrected. + + The resolution now happens from a `kernel:bootstrapped` hook — the earliest phase + strictly after the bind, and the one `SettingsService.reportPreBindRead` names as + the remedy — memoized so it stays one resolution for the life of the transport + rather than a per-call settings read. A host that never fires the boot hooks + resolves it lazily at first use instead, so nothing can deadlock on a hook that + never arrives. + + **Behaviour change on a declared setting**: a deployment that has configured + `localization.timezone` / `localization.locale` / `localization.currency` will + see those values take effect on the stdio MCP surface, where it previously + always received the platform defaults. Formula evaluation (`ctx.timezone`) and + message localization on that surface change accordingly. +- 48318f7: The stdio MCP transport no longer freezes a pre-bind localization when a data call races the boot. #11580 moved the localization read onto a `kernel:bootstrapped` hook, but its lazy entry point memoized whatever the FIRST read produced — and the transport goes live inside `MCPServerPlugin.start()`, before the remaining plugins' `start()` bodies and before every `kernel:ready` handler, which is where `SettingsServicePlugin` binds its data engine. A client fast enough to send a data call in that stretch resolved localization pre-bind, received `UTC` / `en-US` from the manifest defaults, and kept them for the life of the process — the narrow race turning into the permanent wrong value #11580 was about. + + The memo is now armed by the bind rather than by the first read: a resolution taken while the settings bind window is still open is answered but not kept, so a call arriving after the bind (even before `kernel:bootstrapped`) already sees the configured value, and the first resolution taken once the window has closed is the one that lives for the life of the transport. #7279's steady state is unchanged — one resolution, never a per-call settings read — because the `kernel:bootstrapped` hook takes that resolution the moment the window closes. A host that never fires the boot hooks (a bare kernel, a test harness) still answers rather than deadlocking, which is why the read is not made to wait for the bind. +- 8649b39: `CONNECT_AGENT_PAGE` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery can see it. It reaches the kernel through `CONNECT_AGENT_UI_BUNDLE.pages`, but was authored as a bare `export const CONNECT_AGENT_PAGE = { … }` with per-field `as const` — the same shape `MarketplaceInstalledPage` shipped in before #11574, and invisible to the export-shape scan the canonical-envelope gates (#11255, #11480) discover their population with. A new repo-wide gate, `check:page-declaration-shape`, now closes the class: every identifier in a bundle's `pages:` array must be declared `export const X: Page =` or through `definePage()` (#11576). +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 6b3c3788e4..6a6175b7e0 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.2.0", + "version": "17.3.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 07d9f5f3ce..3b72880805 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,521 @@ # @objectstack/metadata-core +## 17.3.0 + +### Minor Changes + +- fa5d137: feat(devx,datasource,automation): published `src/**` may only import workspace packages it declares (#10062) + + A package's non-test `src/**` was free to import any workspace package, + declared or not, and nothing checked it. The class was filed with one member + and a mitigation — the import was type-only, so nothing reached the emitted + JavaScript and rollup-plugin-dts inlined the declaration rather than naming an + unresolvable module. It grew to four members with no signal, and one of them + killed the mitigation: `service-automation/src/flow-precedence.ts` **value** + imports from `@objectstack/objectql`, which it does not declare, and because + the shared tsup config externalises only `dependencies`/`peerDependencies`, the + bundler answered by inlining objectql's implementation into + `service-automation/dist/index.js` — a second copy of another package's code, + kept correct by build configuration alone. + + `pnpm check:undeclared-dep-imports` is the gate, and the per-member fixes here + are decided one at a time rather than by a uniform policy — declaring makes a + coupling real and installable, routing it away removes it, and the two are not + interchangeable: + + * **`@objectstack/service-datasource`** now declares `@objectstack/driver-sql` + and `@objectstack/driver-memory` as **dependencies**. Both are loaded through + an *unguarded* `await import(...)` on the postgres, mysql, sqlite and memory + arms, so a consumer reaching one of those paths needed a package it was never + told to install, and would have met `ERR_MODULE_NOT_FOUND` rather than a + diagnosis. The three *guarded* driver arms — `@objectstack/driver-sqlite-wasm`, + `@objectstack/driver-mongodb`, `@objectstack/driver-turso` — are deliberately + left undeclared: each load sits in a `try`/`catch` that answers an absent + package with the fault, the consequence and the install command, and each + rides as an optional install. Declaring them would install them (turso drags + `@libsql/client`'s native bindings) and, measured on this branch, takes a live + assertion out of the tree: `default-datasource-driver-factory.test.ts` reaches + the missing-package arm with no stub precisely because the package does not + resolve from here. + * **`@objectstack/metadata-core`** now owns the ADR-0029 D9.6 provenance pair, + `isCodeArtifactBody` and `isTenantAuthored`, sunk out of + `@objectstack/objectql`'s registry by the same criterion as the write-verb + dispatch predicates and the audit governance table beside them: a second layer + needs the answer and the reverse import would either close a cycle or make the + consumer depend on the whole data engine for one predicate. `objectql` + re-exports `isCodeArtifactBody` from its original path, so its public API is + unchanged; `service-automation` imports it from `metadata-core`, which it + already declared, and its bundle no longer carries a copy of objectql's code. + + Two members stay recorded rather than remediated, because the tree already + carries the decision not to declare them together with its reason + (`@objectstack/runtime` → `@objectstack/driver-turso`, whose bare `import()` is + a host-replaceable default thunk under #6268; `@objectstack/rest` → + `@objectstack/objectql`, whose absence must degrade to `501 NOT_IMPLEMENTED` + rather than fail module load). Their ledger rows carry mechanical evidence and + go red the moment that evidence stops holding — in particular, a `type-only` + row reds on the day its import becomes a value import, which is exactly the + transition nothing caught the first time. +- 0fd4899: feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957) + + `ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record + would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that + call. Every in-memory test double in the repo instead read an absent filter as + "match everything" and answered happily, so a production call site that violates + #4419 read as *working* under every unit suite and only failed on a real engine. + + That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the + bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a + real engine that throws, the `catch` read the refusal as "users exist", and the + declared first-run bypass became permanently inert on real deployments — while a + 641-line unit matrix over the double stayed green, including a case named + "bootstrap: the very first signup is admitted" (#11767). + + New public API, mirroring the two write-side dispatch predicates + (`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the + implementation lives in `@objectstack/metadata-core` so that packages + `@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql` + re-exports every symbol: + + - `assertEngineFindOnePredicate(object, query)` — the line a fake engine's + `findOne` opens with; throws the engine's own message, object name included. + - `resolveEngineFindOnePredicate(object, query)` — the same decision without the + throw, for a double that wants to classify. + - `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an + assertion pins the producer's wording rather than a paraphrase. + - `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven + against the REAL engine by + `packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate + cannot drift from `engine.ts` unnoticed. + + Nothing is removed and no existing behaviour changes: the engine's own guard is + untouched, and this publishes the decision it already makes so a double can + import it instead of re-deriving it. +- 15eb2c9: feat(security,meta): org-scoped presentation authoring capability `manage_org_presentation` (#12702) + + A tenant org admin in a walled posture can now be granted org-scoped authoring + of exactly the org-overridable presentation types (ADR-0005 tier A: view / + dashboard / report / translation / email_template today — the registry is the + authority) without holding platform-wide `manage_metadata` (maintainer + direction 2026-08-27, quoted in #12701). + + - **spec**: new curated `PLATFORM_CAPABILITIES` entry `manage_org_presentation` + (`scope: 'org'`), seeded into `sys_capability` at boot like its siblings. + Granted by NO shipped permission set — the SaaS operator grants it per + deployment, so existing postures (`single` included) are byte-unchanged by + its existence. + - **metadata-core**: new `metaWriteCapabilityVerdict` — the capability half of + the `/meta` write decision, beside the existing org-scope half + (`organizationIdForMetaWrite`). It admits `isSystem` and `manage_metadata` + exactly as before, and `manage_org_presentation` ONLY when the target type's + registry entry declares `allowOrgOverride: true` (registry-derived via + `declaresOrgOverride`, never a hand-written list) AND the session has an + active organization — the very organization the doors thread, so an admitted + write can only land org-scoped in the caller's own partition: never tier-B, + never env-wide, never another org's. + - **rest**: the four `/meta` item write doors (`PUT` save, `DELETE` reset, + `POST /publish`, `POST /rollback`) run the shared verdict. + `POST /meta/_migrate-stored` stays `manage_metadata`-only — an install-wide + rewrite is env-wide by definition. + - **runtime**: the dispatcher `/meta` `PUT` door runs the same shared verdict + (its `_migrate-stored` twin likewise stays `manage_metadata`-only). + + Refusals keep their transports' existing envelopes (REST `403 FORBIDDEN`, + dispatcher `403 PERMISSION_DENIED`); the tier-B refusal sentence is + byte-identical to before, and the tier-A sentences name the sanctioned path + without disclosing the caller's own grants (#7450). Platform `manage_metadata` + behaviour is unchanged on every door. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- d41d166: fix: the `./testing` subpaths are ESM-only — they no longer advertise a `require` condition vitest refuses to serve (#12985) + + Both packages published their test-harness subpath as a dual entry point: + + ```jsonc + // FROM — @objectstack/metadata-core and @objectstack/service-cluster + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js", + "require": "./dist/testing.cjs" + } + + // TO + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + } + ``` + + The `require` half was a promise neither package could keep. Both subpaths + re-export `vitest`, and vitest **refuses** to be loaded from CommonJS by + design — its CJS entry is a single `throw`: + + ``` + node -e "require('@objectstack/metadata-core/testing')" + Error: Vitest cannot be imported in a CommonJS module using require(). Please use "import" instead. + ``` + + The emitted bytes parse; the load fails inside vitest's own entry, for every + consumer and every code path. So the condition could never resolve to working + code, on any release, since it was first declared. It is removed rather than + repaired because the failure is not ours to fix: a test harness has no business + advertising a `require` condition when the test runner it re-exports does not + serve one. + + **Nothing that worked stops working**, and that is why this is not filed as a + breaking removal. A CJS consumer that resolved through the old condition got a + hard `Error` at load; it now gets a resolution error from node instead — a + different message for the same non-working call, and an earlier and clearer + one. The `import` condition, the types and the runtime API are untouched, and + every in-repo consumer already reaches these subpaths through `import` + (`@objectstack/metadata-fs`, `@objectstack/metadata-protocol`, + `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/service-cluster-redis`). + + **If you did spell it as `require`** — `require('@objectstack/metadata-core/testing')` + or `require('@objectstack/service-cluster/testing')` — switch the call to + `await import('@objectstack/metadata-core/testing')`, or move the calling + module to ESM. That is the same change the old condition already forced on + you, one error message earlier. + + `dist/testing.cjs` is still emitted (both packages build every entry in both + formats) and still parsed by `pnpm check:dual-build-cjs-loads`; it is simply no + longer reachable through the manifest. Removing it from the build is a + tsup-config change with its own risks and is not folded in here. +- 5d16379: **BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230). + + `update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes. + + What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict): + + - A truthy scalar `data.id` beside a **non-scalar** `where.id` — an operator object, an array, `null`, or an explicitly-`undefined` `id` key — now **throws** `UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with both declarations silently ignored. + - Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no** `id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm. + - The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered. + + A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations. + + + +### Patch Changes + +- 54e2d36: Artifacts built by released 17.x tooling boot again on ≥17.2 runtimes: the artifact-ingestion door now runs a versioned ADR-0087 forward conversion before the strict parse (#12772). + + A compiled artifact whose declared `engines.protocol` floor predates the running `@objectstack/spec` version replays the full conversion chain — retired entries included — before validation, exactly the policy the stored-row read path already applies to `sys_metadata` rows. Measured incident: `dist/objectstack.json` built by `@objectstack/cli` 17.1.0 carries the then-legal `allowRestore`/`allowPurge` permission bits (75 of each, injected by the released builder), and spec 17.2.0's `retiredKey` tombstone refused the boot with no operator remedy (`os migrate meta` targets sources, not built artifacts). + + The conversion is versioned, not a blanket amnesty: an artifact authored at the current (or a newer) spec version converts nothing and still refuses at the tombstone — the retired keys return with the M2 lifecycle initiative (#1883), and artifacts authored against that surface are never stripped by history. Conversion notices surface operator-visibly and deduped, one summary line per conversion per artifact. New exports from `@objectstack/metadata-core`: `applyArtifactForwardConversions`, `resolveInstalledSpecVersion`, `parseRangeFloor`, `resolveDeclaredRange`. +- b745157: feat(metadata-core,metadata): warn the operator when a pre-current-era artifact carries form-view predicates that fault open (#12915) + + A form-view predicate binds `record` (+ `previous`, `parent`) in runtime record + forms, or `data` in metadata-editing forms. The contract states the failure mode + beside the vocabulary: **a bare identifier is unbound, the predicate faults, and + `visibleWhen`'s fault fallback is `true`** — so a field the predicate was + authored to hide renders for everyone. + + That is quiet alone and lethal in combination with the authoring pattern it + serves. Measured on a real deployment: an artifact built by released + `@objectstack/cli` 17.1.0 authors + `{ field: 'disqualification_reason', required: true, visibleWhen: 'status == "unqualified"' }` + — the era's working spelling. On a 17.2 runtime the predicate faults open, the + conditionally hidden field renders, and its unconditional `required: true` + blocks **every** record creation through the console, while the same payload + POSTs 201 through REST. Nothing refused and nothing logged, so the operator — + the only person who can rebuild the artifact — had no signal at all. + + The framework artifact door now emits **one deduped `warn` line per artifact** + naming the authored `engines.protocol` floor and the runtime spec version, how + many predicates on which views (with the first path as an anchor), the + fault-open consequence, and the remedy (`os build`). It rides the same funnel + that already carries the forward-conversion summaries, so both SaaS shapes are + covered: a single-DB multi-org runtime warns once at boot, and per-tenant-DB + kernels each warn at their own. + + **No behaviour change.** No refusal, no rewrite, no schema or contract edit — + the predicate keeps faulting open exactly as before, and the artifact bytes are + untouched. Rewriting a bare root to `record.` is a separate, deferred ADR-0087 + conversion. + + **Scoped to legacy artifacts by construction.** The notice fires only inside the + versioned window the forward conversion already opens (declared floor below the + running spec, or undeclared), read off that pass's own verdict rather than + recomputed. An artifact declaring the current or a newer floor gets zero notices + from this feature even when it carries bare roots — the boundary that keeps a + notice about legacy artifacts out of contract territory. + + Detection is exported from `@objectstack/metadata-core` as + `detectUnboundFormViewPredicateRoots` (with `BOUND_FORM_VIEW_PREDICATE_ROOTS`) + so other composed artifact doors can reuse one policy rather than fork it. It is + pure, read-only, and tuned to prefer silence over a false accusation: string + literals are stripped before the scan, only root position counts, call targets + are not roots, comprehension macros (whose iteration variable is locally bound) + are skipped whole, and AST-only envelopes pass. +- d23ebb9: fix(metadata-core,service-cluster): stop emitting and publishing the CJS half of `./testing` (#13013) + + #13001 made both `./testing` subpaths ESM-only, dropping the `require` condition + that pointed at `dist/testing.cjs`. The build kept emitting those files and + `files: ["dist"]` kept packing them, so every release shipped bytes no exports + condition could reach. Measured with `npm pack --dry-run`, before → after: + + | package | files | unpacked | dropped | + |---|---|---|---| + | `@objectstack/metadata-core` | 22 → 16 | 3.3 MB → 3.2 MB | `testing.cjs` (28.0 kB), `testing.cjs.map` (48.4 kB), `testing.d.cts` (9.4 kB), `chunk-H2D6OJ76.cjs` (4.2 kB) + map (10.6 kB), `repository-*.d.cts` | + | `@objectstack/service-cluster` | 15 → 12 | 364.1 kB → 336.9 kB | `testing.cjs` (11.9 kB), `testing.cjs.map` (14.5 kB), `testing.d.cts` (794 B) | + + Nothing reachable changed. The whole ESM surface of both packages — `index.js`, + `testing.js`, their maps, the shared chunk, and every declaration the manifest + names — is **byte-for-byte identical** to the previous build (sha256, before vs + after). `index.cjs` changes only because what was a shared CJS chunk is now + inlined into the sole remaining CJS entry. + + Each `tsup.config.ts` becomes an array of two configs split **by format** — + ESM keeps both entries, CJS takes `src/index.ts` alone. The split is by format + and never by entry: `index` and `testing` share a chunk carrying the error + classes, and one config per entry would give `testing.js` its own copies, so + `ConflictError` reached through `@objectstack/metadata-core/testing` would stop + being the class thrown by `@objectstack/metadata-core` — which the published + contract suite asserts (`.rejects.toBeInstanceOf(ConflictError)`). + + `clean` moves out of tsup and into the `build` script (`rm -rf dist && tsup`). + tsup runs an array config through `Promise.all`, so the halves build + concurrently and a `clean` in either races the other's writes; the script-level + clean is also stronger than tsup's own, which preserves `*.d.{ts,cts,mts}` and + would therefore have left a stale `dist/testing.d.cts` behind on every rebuild + of an existing worktree. +- 2852acc: fix(metadata-core,metadata): split the form-view predicate root vocabulary per surface, so a field-level `current_user` test is not false-flagged (#12915) + + Same-day correction to the unbound-root boot notice. The notice judged **every** + form-view predicate against one vocabulary (`record` / `previous` / `parent` / + `data`), sourced faithfully from the contract prose — which, for the field-level + slot, was stale. + + `current_user` and its ADR-0068 alias roots (`user`, `ctx.user`, `os.user`) + **resolve on a field-level `visibleWhen`** since objectui#6010; three spec text + sites still said otherwise until #12930 re-measured them, and one of those sites + was the sentence this policy was written against. A legacy artifact carrying a + legitimate `current_user.role == "admin"` field predicate was therefore reported + as faulting open — the cry-wolf failure the notice is explicitly built to avoid, + and the one that trains operators to ignore the channel. + + The vocabulary is now per surface, which is what the contract actually says: + + - **Field-level** (`BOUND_FORM_FIELD_PREDICATE_ROOTS`): the shared base plus + `current_user`, `user`, `ctx`, `os`. Silent on all of them. + - **Section-level** (`BOUND_FORM_VIEW_PREDICATE_ROOTS`, unchanged in name and + value): the base alone. `current_user` is still flagged there — the section + docblock states it is unbound at that level and faults open. + + Two limits of the field binding deliberately do **not** change the answer: it is + a rendering rule rather than authorization (an authoring hazard, not a + version-drift one), and the scope is empty on the console's public `/f/:slug` + route (equally true of a freshly built current artifact, so it says nothing + about the artifact's era — the only thing this notice claims to detect). + + The emitted warn line now prints the bound roots **per surface, and only for the + surfaces the findings implicate**, so an operator is never shown a rule their + artifact has no instance of. Findings carry a `surface` field. + + `unboundRootsInCelSource` takes the vocabulary as an optional second argument; + its default is unchanged (the stricter base), so existing callers behave exactly + as before. +- 15d55fb: fix(metadata-core): the CJS entry point loads again — `import.meta` is no longer emitted into `dist/index.cjs` (#12971) + + `@objectstack/metadata-core` declares `"type": "module"` with a dual `exports` + map, so `require('@objectstack/metadata-core')` is a published, supported entry + point. Since 17.2.0 it was **unloadable**: `resolveInstalledSpecVersion()` + anchors its `@objectstack/spec` lookup with `createRequire(import.meta.url)` — + correct for the ESM output — and tsup emitted that identifier **verbatim** into + `dist/index.cjs`. `import.meta` outside an ES module is a **parse-time** error, + so the module never began executing: + + ``` + node -e "require('@objectstack/metadata-core')" + SyntaxError: Cannot use 'import.meta' outside a module + ``` + + The failure was total, not partial. Neither the `typeof require === 'function'` + fast path above the line nor the `try`/`catch` around it ever ran, so **every** + CJS consumer and **every** code path in the package was affected, not just + callers of `resolveInstalledSpecVersion()`. Measured downstream: a walled + enterprise runtime refused to boot because `@objectstack/organizations` + resolves through this condition and the fail-closed tenancy wall correctly + refuses to serve when the organization wall cannot load. + + **The fix** is `shims: true` in this package's `tsup.config.ts` — the same one + line, for the same measured reason, already carried by `@objectstack/runtime` + and `@objectstack/metadata-protocol`. tsup rewrites `import.meta.url` in the + CJS output to a real `__filename`-derived URL, so both formats anchor on the + module's own file and resolve the same `@objectstack/spec/package.json`. Both + conditions now load and `resolveInstalledSpecVersion()` returns the identical + value in each. + + No API, type or behaviour change, and the ESM half is untouched — measured + rather than assumed: rebuilding with and without the shim, the emitted ESM code + bytes are identical in every file, and the whole difference is the shared + chunk's content-hashed **filename** (`chunk-DDDKWTSW.js` → `chunk-46MG4YHS.js`) + and the `sourceMappingURL` line naming it. Chunk names are internal to the + package; no `exports` target moves. Nothing an author writes moves either. + + **The class is now gated.** `pnpm check:dual-build-cjs-loads` (a step in the + required **Build Core** job) parses every emitted CommonJS file and `require()`s + every published `require` entry point in the workspace — 105 entries across 67 + packages — so the next package to leak ESM-only syntax into its CJS output + fails at the commit that introduces it rather than in a consumer's release. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index e9814915f9..d3c22989cf 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.2.0", + "version": "17.3.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 d00aed3c85..4fa498d714 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/metadata-fs +## 17.3.0 + +### Patch Changes + +- a07a831: fix(metadata-fs): declare `startWatcher()`'s chokidar `atomic` option explicitly (#12696) + + `FileSystemRepository.startWatcher()` constructed its chokidar watcher with + `usePolling: true` but never passed `atomic`, leaving it to inherit chokidar's + default. That default is unconditionally `true` in the installed version + (chokidar 5.0.0): the defaults literal assigns `atomic: true` *before* the + caller's options are spread in, so chokidar's own default-correction + (`if (opts.atomic === undefined) opts.atomic = !opts.usePolling`) can never + fire — it only runs when `atomic` is literally `undefined` after the merge, + which it never is. The comment beside that correction ("Editor atomic write + normalization enabled by default with fs.watch") reads as "off under + polling"; the actual resolved behaviour was on regardless. + + This change passes `atomic: true` explicitly at the call site, with a comment + explaining why. **Patch, not a behaviour change**: verified at runtime + (constructing a watcher the way `startWatcher()` does and reading back + `watcher.options.atomic`) that the resolved value is identical before and + after — `true` either way, today. The only thing that changes is that the + value is now DECLARED rather than inherited from an upstream branch that + cannot execute, so a future chokidar release that fixes the ordering (making + the correction real) cannot silently flip this repository's watcher to + `atomic: false` under polling and change behaviour with no diff to review. + + Not addressed here (see #12696): whether `atomic: true` (the 100ms + unlink-coalescing deferral and the `DOT_RE` editor-temp-file matcher it turns + on) is actually the right value. No evidence surfaced that either has ever + affected a run; flipping it to `false` is a deliberate behaviour change to a + live delivery path that needs its own reverse verification, and is out of + scope for this card. +- 3e8f5b0: The file watcher no longer publishes a `delete` for an item that is still on disk. + + A watcher `unlink` is a claim of absence, not absence: chokidar reaches its removal path from failed stats as well as from real removals, so under filesystem pressure it can retire a file that is still there. `FileSystemRepository` published those claims straight through as `delete` events — appended to the change log and broadcast to every subscriber, which drops the item from the metadata registry and the `list()` cache — and the reconciliation sweep then republished the untouched file as a `create`. A failed stat therefore produced a durable delete/create pair for an item nobody removed, with a window in between where live metadata had disappeared. + + The removal face now confirms the absence against the disk under the same per-key lock the reconciliation sweep already used for this, and an `unlink` for a path that still exists falls through to the content comparison — so a spurious unlink that arrived alongside a real external edit surfaces as the `update` it always was. Genuine external removals are unaffected and are still published on the first delivery. +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [d23ebb9] +- Updated dependencies [fa5d137] +- Updated dependencies [0fd4899] +- Updated dependencies [2852acc] +- Updated dependencies [15d55fb] +- Updated dependencies [15eb2c9] +- Updated dependencies [1272f0a] +- Updated dependencies [d41d166] +- Updated dependencies [5d16379] + - @objectstack/metadata-core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 2ef7c0d2f1..8f34728c09 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.2.0", + "version": "17.3.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 cdd7182314..7512b78578 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,535 @@ # @objectstack/metadata-protocol +## 17.3.0 + +### Minor Changes + +- 311433f: feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) + + **BREAKING** accept-set narrowing at the metadata write door, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + Metadata item names — the `name` half of the `type`/`name` pair that keys + `sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: + the empty string, `//`, `'Views/All Leads'` and slash-compound spellings + (`views/all_leads`) were all accepted and stored, and a slash in the name + bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz + name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). + Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. + + The grammar is now **declared in spec** (`MetadataItemNameSchema` / + `METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase + snake_case segments, optionally dot-qualified — the family + `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing + `ViewItemNameSchema` dotted declaration (same segment source, one grammar; the + view-item identity keeps requiring its dot). And it is **enforced at the + publish door** (`saveMetaItem` and `publishMetaItem` in + `@objectstack/metadata-protocol`): an off-grammar name is refused + `400 INVALID_REQUEST` with the grammar and the dotted prescription in the + message, and nothing is persisted. The slash bypass of + `refuseUnmintableMetaType` closes as a consequence. + + **What an author writes instead.** A flat snake_case name (`crm_lead`) and a + dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A + name that spelled a sub-resource with a slash (`views/all_leads`) is + re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity + whose prefix recovers the owner) or flattened with an underscore + (`views_all_leads`); containment is expressed by structure, never by a + separator inside the identity string. A translation item conventionally named + after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in + its required `locale` field (`"zh-CN"`), which has been the item's real + identity key all along. + + Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue + row remains listable and clearable. The in-repo stored corpus was measured at + **zero** slash-bearing item names (#12176 census, re-asserted at land time); + out-of-repo stored slash rows, if any exist, are reported by their deployment's + migrate run rather than rewritten silently. + + +- 457ff75: feat(metadata-protocol): cache the `getMetaItems` overlay read, keyed on the engine write epoch (#11967) + + Leg D (ship-second) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0). + + `getMetaItems` re-read `sys_metadata` on every authenticated request. On the hot + path — `enforceApiAccess` → `loadObjectItems` → `getMetaItems({ type: 'object' })`, + once per REST request — that costs **two** sequentially awaited engine queries + whenever the environment holds no overlay rows for the type, because the empty + first result fires the alt-type retry. An app whose objects are all code-authored + paid both on every request. That read is now cached behind invalidation that is + synchronous and in-process rather than TTL-bound. + + - **Primary trigger — the #11968 engine write epoch**, read structurally rather + than imported: `@objectstack/metadata-protocol` does not depend on + `@objectstack/objectql`, and the substrate declared `WriteEpochLike` separately + for exactly this kind of consumer. Every `sys_metadata` write reaches it, because + `SysMetadataRepository` writes through `engine.insert/update/delete`. + - **Residual bound — `OS_METADATA_OVERLAY_CACHE_TTL_MS`**, default 30s, `0` = off + and a real path. It bounds one thing only: a peer replica's write on a deployment + with no `authz.invalidated` bridge attached. + - **A success is cached ONLY when the engine exposes the write epoch** — leg C's + rule, re-measured and unchanged here. No seam means the cache declines rather + than degrading to a TTL-only shape, which for this leg would make + publish-visibility a timer. Every existing test double keeps its exact query + multiset; only a real engine caches. + + **Grade: `minor`, argued.** Not `patch`: this adds a supported deployment knob + (`OS_METADATA_OVERLAY_CACHE_TTL_MS`, registered in the canonical environment-variable + table) and introduces a bounded staleness window that a multi-node operator must be + able to read about before upgrading — a release note that said only "internal + performance" would under-describe it. Not `major`: no public API changes, no export + is added or removed, `getMetaItems`' request and response contracts are untouched, + and the pins assert the cached answer is identical to the uncached one, so no caller + can observe the difference except in query count. Same grade and same reasoning as + leg C (#11966), which shipped `minor` for the same knob-plus-window shape. + + **The SchemaRegistry-hydration trap (#11633 §4 leg D) is resolved structurally, not + by care.** `getMetaItems` registers overlay rows back into the SchemaRegistry as a + side effect of the read, so a cache that skips the read would quietly stop populating + the registry. What is cached here is the overlay **row set** — the value *upstream* of + the hydration branch — never the merged answer downstream of it. Every consumer of + those rows still runs on every call, hit or miss: the overlay parse, the package-aware + merge, `hydrateOverlayIntoRegistry`, the MetadataService merge, the disabled-package + filter, the nav contributions and the decorations. That containment also keeps the + cache's reach co-extensive with what its key can validate — the SchemaRegistry, the + MetadataService and the artifact table are all mutable sources the write epoch cannot + observe, and none of them is being cached. +- 8d237b4: Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. + + The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. + +### Patch Changes + +- f93df4d: fix(metadata-protocol): the seed-tenancy backfill stops reporting a duplicate-minting hazard on a zero-organization first boot (#12395) + + The `#8686` split diagnostic guarded on `organizationIds.length !== 1`, which folded + two opposite conditions into one loud warning. With **several** organizations the + owner of an untenanted row is genuinely underdetermined and the warning is right. + With **none** there is no second partition at all: every object runs exactly one + `__global__` counter, so the line's claim that the named objects "run two autonumber + counters and can mint the same `unique` identifier twice" was false precisely when a + fresh install read it. (The `organizationLastValue: 0` it reported alongside is the + split probe's `LEFT JOIN` finding no second row, not a second counter at zero.) + + Zero organizations is now its own state — `no-organization-yet`, named after and + matching the 0 / 1 / several line `objectql`'s `resolveSystemWriteOrganization` + already draws — logged at `info` rather than `warn`. It is not silenced: the split + is still reported, because the observation is real even though the hazard is not. + It self-heals at the first sign-up, when the `sys_organization`-insert handoff runs + the same repair against a settled database. + + Two things this deliberately does not change. An organization probe that **failed** + still takes the loud path and now says so — an unreadable probe returns the same + empty array as a genuine zero, and reading it as "no organizations yet" is the + confusion `objectql` fixed in `#9261`. And the repair threshold is untouched: data + is still modified on exactly `organizationIds.length === 1` and nothing else. + + The affected-object list is also now described as what it is — a snapshot taken when + the probe ran. The probe runs at `kernel:ready`, which a boot can reach while an + over-budget inline seed is still writing in the background, so a first boot can name + fewer objects than the settled database holds. +- d2b2381: **Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). + + `saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. + + The maintainer ruled a **split**, and the two halves are not the same fix: + + - **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. + - **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. + + For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. + + The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. +- 376c70f: fix(metadata-protocol): `getDiscovery()` serves a derived `version`, not the hardcoded `'1.0'` literal (#11235) + + `ObjectStackProtocolImplementation.getDiscovery()` filled `DiscoverySchema`'s "System + Identity" `version` field with the constant `'1.0'`. The other `DiscoverySchema` producer + — `HttpDispatcher.getDiscoveryInfo()` in `@objectstack/runtime` — filled the *same* field + with its own constant `'1.0.0'` until #10993 derived it. Two producers of one field + disagreeing with each other is what proves neither literal was ever a contract value: if + `version` were a contract, two producers would not each invent their own constant; if it + is not, it should not be hardcoded. That argument needs no opinion about what `version` + "should" be. + + It now resolves the same way its sibling does: an injected `OS_RUNTIME_VERSION` build + stamp, falling back to this package's own installed version, and `'unknown'` only if both + are unavailable — honest about not knowing rather than a plausible-looking constant. One + stamp, one meaning: a deployment that sets `OS_RUNTIME_VERSION` now gets the same answer + from both discovery producers and from `GET /health`, so the two can no longer drift. + + The resolver is a package-local ~10-line copy of `packages/runtime/src/runtime-version.ts` + rather than a shared import: `@objectstack/runtime` depends on + `@objectstack/metadata-protocol`, not the reverse, so importing it would invert the + dependency direction, and hoisting a helper into `@objectstack/types`/`@objectstack/core` + would widen two packages' published surface for two call sites (declined at #11235 + triage). `tsup.config.ts` gains `shims: true` for the same reason + `packages/runtime/tsup.config.ts` carries it — esbuild empties `import.meta` in a CJS + bundle, so without the shim `require('@objectstack/metadata-protocol')` would have fallen + through to `'unknown'` on every consumer. + + No schema shape changed, no field was added, and no export was widened — only where one + field's value comes from. Patch, matching the sibling fix. +- 78f65ef: Global search (`searchAll`, the producer behind `GET /api/v1/search`) no longer invents an empty registry. The registry read was `registry?.getAllObjects?.() ?? []`, so a host whose registry does not implement `getAllObjects` — a structural omission that never throws — produced a successful "nothing matched" response with `objectsScanned: 0`. A registry that cannot enumerate its objects is never truthfully "no objects" (ADR-0110 D3): both halves of the swallow are removed and the omission now surfaces as the read's own failure, mapped by the REST door's standard error path. Unchanged neighbours: a registry that enumerates and truthfully answers "no objects" still yields the successful empty response, and a blank query still short-circuits before the registry is consulted. +- 33b52fe: fix(metadata-protocol): a metadata app's MARKED refusal is classified as a refusal at the producer, instead of being wrapped as a store outage (#12536) + + A metadata app's sandboxed hook on `sys_metadata` can refuse a read and mark its + refusal with `userMessage` — the #9934 producer-side opt-in where the field's + presence *is* the marking. Every such refusal was handed to + `metadataStoreUnavailableError`, which builds a fresh `Error` carrying only + `code` / `status` / `cause`, and `declaredUserMessage` reads the **top level** + and never `cause`. The mark therefore died at the producer, on the whole + `getMetaItem` / `getMetaItems` read family and on `deletePackage`. + + **FROM** — a marked hook refusal and a `connect ECONNREFUSED` came out of + `getMetaItems` as the same envelope, and no consumer could tell them apart: + + ``` + MARKED hook refusal -> 503 SERVICE_UNAVAILABLE declaredUserMessage=undefined + "The metadata store could not be read, ..." + driver fault -> 503 SERVICE_UNAVAILABLE declaredUserMessage=undefined + "The metadata store could not be read, ..." + ``` + + **TO** — the failure is classified once, at the producer, before it is wrapped: + + ``` + MARKED hook refusal -> 400 (or the status the hook declared for itself) + userMessage = the author's text, verbatim + driver fault -> 503 SERVICE_UNAVAILABLE (byte-for-byte unchanged) + "The metadata store could not be read, ..." + ``` + + `deletePackage`'s per-item path follows the same classification: `failed[]` and + `cleanups[]` gain an optional `userMessage` member, present exactly when the + item's own failure declared the mark. Those rows ride inside a + `PACKAGE_DELETE_PARTIAL` 400's `details`, where no HTTP boundary can carry a + `userMessage` on their behalf, so the channel has to exist on the row itself. + `deleteMetaItem`'s two re-wrap exits carry the mark forward for the same reason + they already carry a catalogued `code`. + + Maintainer ruling 2026-08-27, option B. The declined alternative — forwarding + the mark *across* the 503 — is not implemented and is pinned against: the + store-unavailable door still quotes nothing from `cause`, and a test asserts the + underlying failure text appears in no field outside it. + + **Behaviour that does not change.** An unmarked failure keeps the #8136 503 + exactly as it was: same status, same code, same sentence, same `cause` + relocation. A blank, whitespace-only or non-string `userMessage` is not a + declaration, so nothing invents a marked refusal, and the #3821 generic + substitution stays in force for everything unmarked. + + **For hook authors.** A refusal that names its own `status` / `statusCode` + keeps it (#7867). One that names none is answered 400 — the same default the + REST sandbox door already applies to an undeclared hook refusal (#9967) — with + the catalogued code for that status; declare `status` (and a catalogued `code`) + when a different classification is wanted. +- ece4dad: Pin the declaration emitter's module specifier for `FormFieldInput` to `@objectstack/spec/ui` (#11350). When #11350 made the three ui/automation input types nameable from `@objectstack/spec`'s root entry, tsc's declaration emitter for this package switched its synthesized reference for `FormFieldInput` from the `/ui` slice to the root entry — both portable, but the root specifier pulls spec's entire root module graph into every downstream TypeScript program that reads this package's declarations (measured: +190k types, +805k instantiations, roughly +560MB on one real program). A local type-only import binding keeps the emitted reference on the narrow `/ui` entry. Type-only and erased at runtime: every emitted JS file is byte-identical; the package's public export surface is unchanged. +- bd0c5cc: fix(metadata-protocol): a failed `sys_organization` probe is no longer answered as "no sole organization" (#12852) + + `SeedLoaderService.resolveSoleOrganizationId()` sat behind a bare `catch {}` whose + comment named ONE benign cause — "sys_organization may not exist (single-tenant + runtime)" — while the `catch` swallowed every cause. A dropped connection, a + timeout, a permission refusal or a driver fault all arrived at the caller as + `undefined`, which is not a neutral value here: it is the verdict the method's + own JSDoc calls "genuinely ambiguous", so `load()` stamped no `organization_id` + and every BUSINESS seed row of that run landed org-less — invisible afterwards + under strict org-scoping. Nothing reported it either: `SeedLoadResult` carries an + `errors` field and this path never touched it, so the operator saw a clean, + successful seed. + + The repair is the one already landed on the sibling probe across the engine + boundary (`ObjectQL.probeInstallOrganizations`, #9817), copied: bind the + parameter and ask the declared predicate. Only an unprovisioned TABLE is + truthful emptiness — the exact cause the swallowed comment already named — so + the JSDoc's "or when `sys_organization` is absent" stays true, while every other + cause now propagates with its envelope intact. + + Bump argued, not defaulted: `patch`. No exported signature, type or option + moves, and the declared answer for every case the JSDoc describes is unchanged. + What changes is a failure path — a seed run that used to complete while writing + invisible rows now fails loudly — which is the correction of a defect rather + than a new capability. The three landed repairs in this family (#8896, #8906, + #9817) all shipped as `patch`, and this is the site that pass missed. +- 9a884c6: Seed pass 2 now writes a deferred reference back through the internal id captured at insert time, so a keyless dataset (`mode: 'insert'`, no `externalId` — the honest authoring for an engine-owned object with no natural key) heals an out-of-order reference exactly like a keyed one. Previously pass 2 re-resolved the source row through its `externalId`, so a keyless (or empty-keyed) row's resolved reference was dropped loudly and the load reported `success: false`; the same load now succeeds with the reference healed. The loud drop remains for the one case where it is true — the source row's pass-1 write failed, so there is nothing to write back onto. Unchanged and now measured against the real engine: a deferred column that is `required: true` is still rejected at pass-1 insert (the deferral deletes the column), so such datasets must still seed their target first. +- 1cba33f: fix(metadata-protocol): warn at load time when a seed defers a `required` column, before the engine rejects the row (#11674) + + The seed loader defers an unresolvable reference to pass 2 by **deleting the + column** from the pass-1 row. On a `required: true` column that turns the insert + into one the write contract rejects (ADR-0113), so the row never lands and pass + 2 has no row to back-fill — measured against the real `ObjectQL` engine in + `packages/objectql/src/engine-seed-required-deferral.test.ts`. It is the second, + independent road to the same loud failure that the pass-2 internal-id write-back + could not clear, and it is why a `required` id half stays **order-dependent** + even though a declared pointer pair contributes no static ordering edge. + + Nothing about that failure was quiet — it is a write error naming the column, a + dropped-deferral error, and `success: false`. What was missing is **when** the + author learns: only after the engine rejected the row, from a driver-level + message that does not mention seeding order. The loader now says it first. + + - **Load-time warning, scoped to the required subset.** When a dataset defers a + reference on a column the write contract requires on insert, the loader logs + one `warn` naming the object, the field, the target object that is not seeded + yet, what deferring did to the row, and the fix — order the target dataset + first. Emitted before the row reaches the engine, once per dataset and field + rather than once per row. + - **⛔ The accept set is unchanged.** Nothing is counted, nothing reaches + `result.errors`, `success` is untouched, and every existing loud failure and + its tests are preserved verbatim. Only the log gains a line. + - **The predicate mirrors the write contract instead of approximating it** + (`required && !readonly && !system`, and only on rows headed for an INSERT). + Two configurations were measured on the real engine where a deferral on a + `required` column is accepted today — an upsert replay taking the UPDATE arm + (an omitted column is not a cleared one) and a `readonly` required column + (required-validation skips it on insert) — and the warning correctly stays + silent on both. They are pinned as controls. + + The constraint is also now documented at the four pointer-pair declaration + sites: `sys_approval_request`, `sys_record_share` and `sys_share_link` must seed + the target dataset first; `sys_audit_log` (optional id half) is genuinely + order-independent and says why it differs. +- da1126a: fix(metadata-protocol): the #8686 tenancy backfill writes the merged autonumber high-water mark before it deletes anything (#12394) + + The seed/API tenancy handoff destroyed the counter it was supposed to move. It ran two + independent statements — an `UPDATE` of the organization-scoped `_objectstack_sequences` + row, then an unconditional `DELETE` of the `'__global__'` one — and on a **fresh install** + there is no organization-scoped row yet, because no API create has happened. The `UPDATE` + matched nothing, which is a success on every dialect; the `DELETE` ran regardless; the + counter table was left empty. `SqlDriver.getNextSequenceValue` then re-entered the + `if (!existing)` bootstrap its own docstring reserves for first allocation, re-derived the + counter from `MAX(data)`, and **re-issued a business identifier that had already been + handed out** — measured on 17.1.0: `ACC-000009` on two different records. + + The zero-row case is the *normal* first-boot shape, not an edge case: it is precisely the + shape `buildSplitProbeSql`'s `LEFT JOIN` was widened to catch, so the repair fired on + exactly the installs where its merge loop body never executed. + + The handoff is now one ordered decision per scope: + + 1. **write** the merged mark — `INSERT` when the organization-scoped row is absent, + `UPDATE` when it exists; + 2. **read it back** — "the statement did not throw" was never evidence a row was written, + and an `UPDATE` matching zero rows is exactly the defect above; + 3. **then** retire the `'__global__'` row, addressed by its own stored `key_hash`, so a + retirement can only ever hit the row whose mark was just merged. + + A throw at any step leaves the `'__global__'` row in place — which is the state the next + boot's split probe detects and retries — so a failed repair now loses nothing. + + Per **scope**, because a `{YYYYMMDD}` / `{field}` / per-parent format runs one counter row + per rendered prefix. The old merge was scope-blind in both directions: it could raise every + scope's counter to one merged value, and it deleted every scope's `'__global__'` row. + + The merge rule itself is unchanged and is the 2026-08-15 ruling's: the greater of the two + **counters**, never the data max. That rule is the whole point — a counter is allowed to + sit ahead of its rows (a rolled-back insert burns a number, by design), and that gap is + exactly what the old handoff threw away. + + Graded **patch**: a defect repair inside an existing migration. It adds no export to + `@objectstack/metadata-protocol`'s public index — the new SQL builders are module-scoped + for their own unit tests, matching the index's own recorded rule that an export added so a + test can import a value is the shape to catch before it ships. + + No change to the allocator. Reaching `if (!existing)` is not evidence of lost state — a new + tenant, a new day and a new `{field}` group each reach it legitimately, and a destroyed + counter leaves no row behind to tell the two apart — so a guard there would fire on the hot + path and still not detect this. The repair belongs where the state was destroyed. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [32448d4] +- Updated dependencies [54e2d36] +- Updated dependencies [7ef0268] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [09ae32e] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [12e306a] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [f4e7ae5] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [68c5dba] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [365e334] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [20a452e] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [ba8420b] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [f213793] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/lint@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 205e489483..32277f2774 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.2.0", + "version": "17.3.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 94f9199d0b..3a0d8d2cf3 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,446 @@ # @objectstack/metadata +## 17.3.0 + +### Minor Changes + +- 32448d4: feat(metadata): the artifact door registers stack-declared `capabilities` (#12892 step 1) + + `ARTIFACT_FIELD_TO_TYPE` — the map that decides which collections of a compiled + artifact reach `MetadataManager` — now carries `capabilities: 'capability'`. + This is step 1 of the maintainer's 2026-08-29 ruling on #12892 (option 1: *the + door owns the registration route* for the five artifact security collections). + + **FROM.** `capabilities` is an authorable top-level stack collection (ADR-0066 + D1), but the door did not map it while `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` + block did — making that block the collection's **sole registrar on an artifact + boot**, and it registers the raw bundle bytes with no strict parse, no schema + default and no ADR-0010 provenance. On a `bootstrap: 'artifact-only'` runtime + where `AppPlugin` does not run, a package's declared capabilities reached no + registry at all: `GET /meta/capability` answered **empty**, and + `bootstrapDeclaredCapabilities` seeded **no `sys_capability` row** for them. + + **TO.** The door registers them like every other mapped collection: strict + parse, schema defaults, ADR-0010 provenance. Measured on a real artifact-only + kernel boot with no `AppPlugin`, over a package declaring + `{ name: 'crm.export', label: 'Export CRM data' }`: + + - `GET /meta/capability` went from `[]` to one item carrying `scope:'platform'` + (the `CapabilitySchema` default) plus `_packageId` / `_packageVersion` / + `_provenance`; + - `sys_capability` went from 9 rows (platform-curated only) to 10 — the + declaration now materializes with `managed_by:'package'` and its `package_id`. + + **What this does NOT change, deliberately.** On the ordinary artifact boot + `AppPlugin` still registers `capabilities` and still runs last, so its unparsed + copy still wins the registry — measured byte-identical before and after this + change. Two registrars on one route is the interim state the ruling explicitly + permits while step 2 (that block stops registering the five on the **artifact** + path, after a census of the non-artifact boots that depend on it) lands. No + authoring surface moves, and no artifact that parses today stops parsing. +- f4e7ae5: fix(security): gate the metadata HMR door on an explicit development posture (#12140) + + **BREAKING** surface narrowing — `GET`/`POST /api/v1/dev/metadata-events` are no + longer mounted unless `NODE_ENV` is exactly `development`. Shipped as `minor` + under the repo's launch-window convention for breaking changes. + + `MetadataPlugin` mounted both HMR routes whenever a raw-app-capable HTTP server + was present, with no authentication and no environment condition of its own. The + dev-only posture the path (`/api/v1/dev/…`) advertises lived only in prose — + "production deployments simply won't have a CLI POSTing to this endpoint" — which + is a claim about who is on the network, not a gate that stops them, and the same + structural shape #9391 closed for the `datasource-admin` family: both mounts take + the host's framework-native app handle, which is outside REST's `enforceAuth` + seam by construction. + + The distributions were enumerated rather than assumed, because "a dev-only + surface lacking a gate that says so" and "an unauthenticated door on a real + deployment" want different repairs. The official container image runs `os start` + under `NODE_ENV=production`; that boot reaches `createStandaloneStack`, which + composes `MetadataPlugin` unconditionally (only `artifactWatch` was + NODE_ENV-gated) onto a kernel that registers the Hono server whenever it serves. + So a production-shaped boot did mount both routes and did answer them — `POST` + re-reading the compiled artifact from disk and broadcasting a reload frame to + every connected client, unauthenticated. + + `registerMetadataHmrRoutes` now refuses at its first statement, ahead of every + side effect it performs, and returns `null` so "nothing was mounted" is a fact + the compiler forces its caller to handle. Unset `NODE_ENV` is closed, per the + maintainer's 2026-08-06 ruling that an absent value reads as `production`; `test` + and unrecognised spellings (`staging`, `preview`, `qa`) are closed too — a gate + must not treat a spelling nobody recognises as a key. + + Who is affected, in both directions: + + - `os dev` is unchanged. It spawns `os serve --dev`, which sets + `NODE_ENV='development'` before any plugin starts, so the watch-recompile loop + still gets its `200` and Studio still gets its reload frames. + - A deployment that was reaching this endpoint on a production-posture boot now + gets its host app's `404`. That door was never advertised, never in the SDK + (`@objectstack/client` builds no such URL) and is a build-tool loopback; if you + need a supported production reload trigger, that is a product decision, not + this endpoint. + - The server-side artifact-file watcher is untouched on every boot shape that had + it: the reload still happens, only the broadcast to (now absent) SSE clients is + skipped. + + + +### Patch Changes + +- 54e2d36: Artifacts built by released 17.x tooling boot again on ≥17.2 runtimes: the artifact-ingestion door now runs a versioned ADR-0087 forward conversion before the strict parse (#12772). + + A compiled artifact whose declared `engines.protocol` floor predates the running `@objectstack/spec` version replays the full conversion chain — retired entries included — before validation, exactly the policy the stored-row read path already applies to `sys_metadata` rows. Measured incident: `dist/objectstack.json` built by `@objectstack/cli` 17.1.0 carries the then-legal `allowRestore`/`allowPurge` permission bits (75 of each, injected by the released builder), and spec 17.2.0's `retiredKey` tombstone refused the boot with no operator remedy (`os migrate meta` targets sources, not built artifacts). + + The conversion is versioned, not a blanket amnesty: an artifact authored at the current (or a newer) spec version converts nothing and still refuses at the tombstone — the retired keys return with the M2 lifecycle initiative (#1883), and artifacts authored against that surface are never stripped by history. Conversion notices surface operator-visibly and deduped, one summary line per conversion per artifact. New exports from `@objectstack/metadata-core`: `applyArtifactForwardConversions`, `resolveInstalledSpecVersion`, `parseRangeFloor`, `resolveDeclaredRange`. +- 7ef0268: fix(metadata,runtime): retire the `policies` dead pointer in both artifact registrars, and pin the map that carried it (#12894) + + Zero behaviour change, by construction. Both readers of an artifact boot carried + a `policies` -> `policy` entry — the artifact door's `ARTIFACT_FIELD_TO_TYPE` + (`packages/metadata/src/plugin.ts`) and `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` + list (`packages/runtime/src/app-plugin.ts`) — and **neither could ever match**. + `ObjectStackDefinitionSchema` is a `strictObject` that declares no top-level + `policies` key, so a definition carrying a `policies` array is refused outright + by the door's strict parse and reaches neither registry. The word is real but + lives one level down: on a permission set `policies` is an alias for + `rowLevelSecurity` (`PERMISSION_SET_KEY_ALIASES`) — a key on an **item**, never a + collection. Both entries are removed, each leaving in place the note the map + already writes for a retirement: what it pointed at, and why it could not match. + + That was the third entry retired from `ARTIFACT_FIELD_TO_TYPE` for exactly this + reason (`themes`, then `roles` -> `positions`, which "matched nothing and + silently dropped compiled positions"). So the deletion ships with the thing the + two predecessors did not have — a check that fails when the pattern recurs: + + - `check:stack-collection-maps` now reconciles **eight** hand-maintained + enumerations against the schema, not seven. `SECURITY_FIELDS` is the new + eighth, and how it was missing is the finding rather than a footnote: it is + the only one of the eight that pairs its keys as `[collection, kind]` tuples, + which neither existing extractor could read, so the site was skipped rather + than reported. Re-adding `policies` — or any other key the schema does not + declare — to **either** registrar now fails the gate with the site named. + - A new `tupleFirstItems` extractor reads that shape, with a self-test case + (13 assertions, up from 12) covering the comment/nesting cases the flat + string-array extractor already pins. + + The mirror-image half of the same measurement is **carried, not shipped**: + `capabilities` is a declared top-level collection that `SECURITY_FIELDS` + registers and the door's map does not, making `AppPlugin` its sole registrar on + an artifact boot. Adding it to the door changes what an artifact boot registers, + so it is measured and handed to the route-ownership decision (#12892) instead of + being smuggled in here. The new waiver row records the asymmetry in place. +- b745157: feat(metadata-core,metadata): warn the operator when a pre-current-era artifact carries form-view predicates that fault open (#12915) + + A form-view predicate binds `record` (+ `previous`, `parent`) in runtime record + forms, or `data` in metadata-editing forms. The contract states the failure mode + beside the vocabulary: **a bare identifier is unbound, the predicate faults, and + `visibleWhen`'s fault fallback is `true`** — so a field the predicate was + authored to hide renders for everyone. + + That is quiet alone and lethal in combination with the authoring pattern it + serves. Measured on a real deployment: an artifact built by released + `@objectstack/cli` 17.1.0 authors + `{ field: 'disqualification_reason', required: true, visibleWhen: 'status == "unqualified"' }` + — the era's working spelling. On a 17.2 runtime the predicate faults open, the + conditionally hidden field renders, and its unconditional `required: true` + blocks **every** record creation through the console, while the same payload + POSTs 201 through REST. Nothing refused and nothing logged, so the operator — + the only person who can rebuild the artifact — had no signal at all. + + The framework artifact door now emits **one deduped `warn` line per artifact** + naming the authored `engines.protocol` floor and the runtime spec version, how + many predicates on which views (with the first path as an anchor), the + fault-open consequence, and the remedy (`os build`). It rides the same funnel + that already carries the forward-conversion summaries, so both SaaS shapes are + covered: a single-DB multi-org runtime warns once at boot, and per-tenant-DB + kernels each warn at their own. + + **No behaviour change.** No refusal, no rewrite, no schema or contract edit — + the predicate keeps faulting open exactly as before, and the artifact bytes are + untouched. Rewriting a bare root to `record.` is a separate, deferred ADR-0087 + conversion. + + **Scoped to legacy artifacts by construction.** The notice fires only inside the + versioned window the forward conversion already opens (declared floor below the + running spec, or undeclared), read off that pass's own verdict rather than + recomputed. An artifact declaring the current or a newer floor gets zero notices + from this feature even when it carries bare roots — the boundary that keeps a + notice about legacy artifacts out of contract territory. + + Detection is exported from `@objectstack/metadata-core` as + `detectUnboundFormViewPredicateRoots` (with `BOUND_FORM_VIEW_PREDICATE_ROOTS`) + so other composed artifact doors can reuse one policy rather than fork it. It is + pure, read-only, and tuned to prefer silence over a false accusation: string + literals are stripped before the scan, only root position counts, call targets + are not roots, comprehension macros (whose iteration variable is locally bound) + are skipped whole, and AST-only envelopes pass. +- 2852acc: fix(metadata-core,metadata): split the form-view predicate root vocabulary per surface, so a field-level `current_user` test is not false-flagged (#12915) + + Same-day correction to the unbound-root boot notice. The notice judged **every** + form-view predicate against one vocabulary (`record` / `previous` / `parent` / + `data`), sourced faithfully from the contract prose — which, for the field-level + slot, was stale. + + `current_user` and its ADR-0068 alias roots (`user`, `ctx.user`, `os.user`) + **resolve on a field-level `visibleWhen`** since objectui#6010; three spec text + sites still said otherwise until #12930 re-measured them, and one of those sites + was the sentence this policy was written against. A legacy artifact carrying a + legitimate `current_user.role == "admin"` field predicate was therefore reported + as faulting open — the cry-wolf failure the notice is explicitly built to avoid, + and the one that trains operators to ignore the channel. + + The vocabulary is now per surface, which is what the contract actually says: + + - **Field-level** (`BOUND_FORM_FIELD_PREDICATE_ROOTS`): the shared base plus + `current_user`, `user`, `ctx`, `os`. Silent on all of them. + - **Section-level** (`BOUND_FORM_VIEW_PREDICATE_ROOTS`, unchanged in name and + value): the base alone. `current_user` is still flagged there — the section + docblock states it is unbound at that level and faults open. + + Two limits of the field binding deliberately do **not** change the answer: it is + a rendering rule rather than authorization (an authoring hazard, not a + version-drift one), and the scope is empty on the console's public `/f/:slug` + route (equally true of a freshly built current artifact, so it says nothing + about the artifact's era — the only thing this notice claims to detect). + + The emitted warn line now prints the bound roots **per surface, and only for the + surfaces the findings implicate**, so an operator is never shown a rule their + artifact has no instance of. Findings carry a `surface` field. + + `unboundRootsInCelSource` takes the vocabulary as an optional second argument; + its default is unchanged (the stricter base), so existing callers behave exactly + as before. +- e7f56d6: Make the settings engine facade and the metadata database loader bind the row + they resolved, not a row the payload names + + Two ingresses resolved an authoritative row id and then folded it into the + write payload with the **losing** spread order — `{ id, ...data }` — so a + caller-supplied `data.id` spread over the id the ingress had just resolved and + silently retargeted the write: + + - `wrapEngineAsSettingsEngine`'s by-id `update` branch + (`@objectstack/service-settings`), whose id comes from the caller's + `where.id`. + - `DatabaseLoader._update` (`@objectstack/metadata`), whose id arrives as a + separate parameter every caller resolves first (`existing.id`, from the read + immediately above). + + Both now spell it `{ ...data, id }` — the operation's id **after** the spread, + so it wins. That is the convention the repo's other two ingresses already + document: `rest-server.ts`'s batch update arm ("the operation's id AFTER the + spread, so it wins") and `protocol.updateData`'s #6479 fix + (`{ ...request.data, id: request.id }`). + + **No wrong write is known to have been reachable.** Both sites' current callers + build fresh field literals and never put an `id` inside `data`, so this is + hardening a fragile pattern rather than repairing a measured defect. What makes + it worth the three characters is that neither site can be caught downstream: + both pass **no `where`** to the engine, so the payload is the only id the engine + ever sees, and the engine's conflicting-id refusal (`UPDATE_ID_MISMATCH`, 400) + needs two disagreeing declarations before it can fire. The fold is the entire + trust boundary at both sites, and it is one refactor — a caller handing back a + row copy, and rows carry `id` — from the #6479 shape. + + Both are pinned with a payload whose `id` names a **different** row than the + one the ingress resolved, asserting the resolved row is still the row bound. A + pin exercising a payload without an `id` would have passed against both + spellings. The doubles answer "which row does this bind?" with the producer's + own `assertEngineUpdateDispatch`, so they cannot be kinder about it than a + running server. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [a07a831] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [3e8f5b0] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/metadata-fs@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index b433431d38..cb313c0932 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.2.0", + "version": "17.3.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 ccc644d750..c1a8cc37d6 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1261 @@ # @objectstack/objectql +## 17.3.0 + +### Minor Changes + +- 4bd6faa: feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) + + The substrate step (§10.3) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation + machinery once, before the grants cache (#11967) that will consume it, so that + leg does not carry it. **Nothing here caches anything.** + + - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine + middleware seam on every `insert` / `update` / `delete`, ahead of the whole + chain (and so ahead of any `isSystem` bypass a middleware applies). It + generalises the private counter `@objectstack/plugin-security` has carried + since #10757: the mechanism was always the engine's, and hoisting it lets a + second consumer share **one** signal instead of minting a parallel one that + watches a different set of writes. A seam rather than a list of call sites, + because a forgotten call site fails as silent over-permission and writing + through the engine is the only way to write at all — including better-auth's + own adapter. + - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged + in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a + consuming cache carries is the correctness contract; this channel is not.** No + shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a + missed message is *expected*, the bridge stays out of the write path (a + publish failure is logged and swallowed, never awaited by the writer), and the + channel only moves the *typical* convergence from one TTL to one network hop. + That statement lives in the code at the channel, where a consumer reads it. + - **The boot-time posture statement** — non-optional by the ruling. Whenever a + grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no + cross-node invalidation bus, the deployment is told so at `warn`, every boot, + naming the window it accepted and the remedy. It is a statement, not a + refusal: a TTL-bounded per-process cache is a legitimate configuration. It is + said out loud because a silently-absent invalidation bridge is how a security + control gets disabled with nobody noticing (#4785). The in-process `memory` + driver counts as **no** bus — a cluster service exists on the shipped default + while fanning out to nobody, which is the case a "is a cluster service + registered?" check answers `yes` to and is wrong about. + + **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero + subscribers, so nothing is published and nothing is invalidated; with the + shipped default TTL of `0` the bridge attaches nothing and logs nothing above + `debug`. The one composition change worth naming: `Runtime` now registers + `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` + — that is not an oversight, it is the loudest case the posture check has, and + skipping it there would put the statement's absence exactly where the missing + bus is. + + `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads + the engine's epoch when the wired engine exposes one and keeps its private + counter otherwise (test doubles, embeddings). The covered set of writes is + identical — the plugin's own middleware was already global — and it is now + identical *by construction* rather than by two files agreeing on which + operations count. +- 0010797: Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). + + `IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and + only when — the referenced parent table lives outside the introspecting session's resolution + scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's + database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot + cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a + separate key, never a conditional spelling. + + `convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target + carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` + (default `console`) instead of being wired to the bare name — which either resolved to nothing + or to a same-named table in the current schema, silently. The column is kept as a plain field so + its data stays visible. Foreign keys with in-scope targets keep producing identical lookup + fields. +- 0db5520: feat(objectql): retain and expose `external.credentialsRef` on datasource definitions (#12758) + + `ObjectQL.registerDatasourceDef`'s parameter type carried only `name`, + `schemaMode` and `external.allowWrites`, so a caller passing a fresh object + literal with `external.credentialsRef` was refused by excess-property checking + (`TS2353`) — while the docs (`/docs/data-modeling/external-datasources`) + prescribe exactly that key on a code-declared datasource, and + `@objectstack/spec` has declared it all along on + `ExternalDatasourceSettingsSchema`, valid in every `schemaMode` (#8153). The + engine also exposed **no reader at all** onto its datasource index; its sole + consumer was the private write gate. + + Measured before anything was changed: nothing stripped the reference at + runtime. The writer stores the caller's `external` object whole, by reference, + and the package-manifest install path spreads the def straight through — so the + value was already in the index, unreachable to every typed producer and every + consumer. The defect was type-level, and the fix is a widening plus the + accessor that was missing. + + - `registerDatasourceDef` now takes the named, exported `DatasourceDef`, whose + `external` block carries `credentialsRef?: string` beside `allowWrites`. + Retention, not invention: the key is the spec's, and every shape that + compiled before still compiles. + - New `ObjectQL.listDatasourceDefs()` answers every definition the engine + holds, from both entry routes. Deliberately unfiltered — `credentialsRef` is + valid on a managed datasource too, so filtering by schema mode would hide + live handles from a `sys_secret` reference sweep, and under-reporting is the + direction that deletes live credentials. Each entry carries a copied + `external` block so a reader cannot reach through it and mutate the write + gate's own input. + + Why this matters beyond tidiness: a datasource declared **in code** never + reaches `sys_metadata`, so the cross-producer `sys_secret` reference union + (#12663) cannot see the handle it holds and must be handed the list by its + host. That makes the completeness of the union — the precondition an orphan + sweep's deletion predicate rests on — depend on every caller remembering to + pass a list. This moves the guarantee from process to mechanism. The union is + not rewired here; that is consumer-side work on a shipped contract and is + tracked separately. + + The write gate is untouched: it reads `schemaMode` + `allowWrites`, the new key + is inert to it, and both directions of the gate stay pinned. + + **Why `minor` and not `patch`.** Zero runtime behaviour changes, which is the + honest case for `patch` — but the bump describes the **contract**, not the + bytes executed, and this release adds public API three ways: a new public + method (`listDatasourceDefs`), a newly exported type (`DatasourceDef`), and a + widened accepted set on an existing public method (calls that were rejected at + compile time now compile). A consumer pinning `~` would receive new API under a + `patch`, which misdescribes the release. Nothing is removed, narrowed or + renamed, so no breaking-change declaration and no ADR-0087 entry arise; `minor` + is the additive-surface bump, not the launch-window breaking convention. +- 3b4c56c: fix(objectql,spec): run the pre-delete reference check under the system identity (#12166) + + **Grade: `minor`, not `patch` — argued, because a permission-behaviour change + should not arrive as a bug-fix bump.** Configurations that returned `403` now + return `200`. Nothing gets more restrictive and no API changes shape, so this + is not `major`; but "records a role could never delete are now deletable" is a + security-surface accept-set change an upgrader must be able to see in a + release-notes heading, and a `patch` line is exactly where it would not be + looked for. The spec half ships `minor` alongside because the message catalog + gains two keys. + + Deleting a record runs the platform's pre-delete reference check, which issues + a `find` against every referencing object. That probe ran as the **calling + operator**, so a caller with full delete rights on the target but no read grant + on any referencing object got a blanket `403 PERMISSION_DENIED` — regardless of + whether a reference actually existed. An **empty** referencing table 403'd too. + The reporting deployment (`@objectstack/*@17.2.0`) measured 17 role×object + pairs where the UI shows a delete button that always fails, with the A/B + control that granting read-only on the referencing object — touching *nothing* + about delete rights — turned the identical operation into a `200`. + + It silently made "delete permission" mean "delete **plus read on every + referencing table**", a coupling invisible in the permission UI and impossible + for an administrator to self-diagnose: the refusal said only "You do not have + permission to perform this action." + + Referential-integrity actions are engine responsibility executed under system + identity on every mainstream platform — the RDBMS FK baseline, Salesforce + (lookup clearing and cascade delete documented as bypassing sharing), + Dataverse, ServiceNow, Odoo. Caller identity here was the outlier. Maintainer + ruling 2026-08-26, option A. + + **What changed.** The dependents probe now runs `sudo()`-shaped — + `{ ...context, isSystem: true }`, following the in-repo precedent in + `packages/objectql/src/integrity/dangling-reference-audit.ts`. The spread is + load-bearing: the caller's open transaction handle, **tenant scope** and + `userId` all survive, so the probe does not leave the caller's transaction and + does not read across the tenant wall. + + **What did NOT change.** Only the reference *check* switches identity. The + caller's own delete authorisation on the target is untouched; the `set_null` + `UPDATE`, the `cascade` `DELETE` and the target's own delete all still run as + the caller. A caller without delete rights on the target is refused exactly as + before — pinned in both directions, because a relaxation must not become a + hole. + + **Refusal copy.** Because the probe now sees rows the caller may hold no read + grant on, `DELETE_RESTRICTED` discloses the dependent **count** only when the + caller's own identity would have produced the same rows (compared on row + identity, so row-level narrowing counts too). Otherwise the count is withheld + and the refusal renders one of two new catalog keys, + `delete_restricted_opaque` / `delete_restricted_required_opaque` — the same + sentences minus `{{count}}`, in all four bundled locales. Without that, the + refusal would be an exact, repeatable cardinality oracle over a table the + caller may not read. The referenced **object** and the relation field are named + either way: those are declared metadata, and they are the whole of what makes + the refusal self-diagnosable. `dependentCount` is **absent** rather than `0` in + the withheld case — `0` would be a false statement about the rows. + + **Audit.** The elevation is filed with both halves, the Salesforce/Dataverse + ledger shape: `triggeredBy` = the deleting operator, `executedAs: 'system'`, + plus the referenced object and relation field — never a row id, value or count. + It is filed *before* the probe, so a refused or failed check is recorded too. + Declared limit: this is an engine **log** record, not a `sys_audit_log` row — + the elevated operation is a read, and `plugin-audit`'s read writer declares and + pins that a system-elevated read produces no row. A durable row belongs to the + plugin that owns that shape. + + **Upgrade note.** If a deployment was relying on the `403` as a de-facto delete + gate, that gate is gone. Under the industry baseline such usage is itself + non-standard and should be expressed as an explicit `deleteBehavior: 'restrict'` + on the relationship rather than as a read-permission side effect. No such + reliance was measured; the ruling records this as a known confidence gap. +- 0fd4899: feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957) + + `ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record + would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that + call. Every in-memory test double in the repo instead read an absent filter as + "match everything" and answered happily, so a production call site that violates + #4419 read as *working* under every unit suite and only failed on a real engine. + + That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the + bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a + real engine that throws, the `catch` read the refusal as "users exist", and the + declared first-run bypass became permanently inert on real deployments — while a + 641-line unit matrix over the double stayed green, including a case named + "bootstrap: the very first signup is admitted" (#11767). + + New public API, mirroring the two write-side dispatch predicates + (`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the + implementation lives in `@objectstack/metadata-core` so that packages + `@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql` + re-exports every symbol: + + - `assertEngineFindOnePredicate(object, query)` — the line a fake engine's + `findOne` opens with; throws the engine's own message, object name included. + - `resolveEngineFindOnePredicate(object, query)` — the same decision without the + throw, for a double that wants to classify. + - `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an + assertion pins the producer's wording rather than a paraphrase. + - `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven + against the REAL engine by + `packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate + cannot drift from `engine.ts` unnoticed. + + Nothing is removed and no existing behaviour changes: the engine's own guard is + untouched, and this publishes the decision it already makes so a double can + import it instead of re-deriving it. +- c3c72a4: Record file-field hydration now answers the same question about a `sys_file` tombstone that the download path answers (#11427). `#10246` stopped `GET /api/v1/storage/files/:id` treating a tombstone (`status: 'deleted'` + `deleted_at`) as the last word — it asks the reap guard's own `findFileHolder` and serves the row for as long as something still holds it — but the record read kept the older `status === 'committed'` rule. One `sys_file` row therefore answered `200` at the download endpoint and a bare id inside a record payload, which UI and export render as "this record has no attachment". + + The population is narrow and unchanged in every other respect: `claimFile` already un-tombstones a field file synchronously when a record re-points at it, and attachments-scope files are never reached by field hydration, so what this closes is the residual the reap guard's sweep-time re-verification names — hook races, direct-driver writes, and future trash restore. A tombstone nothing holds still hydrates as a bare id, and a `pending` upload is untouched. + + The predicate is not re-derived in the engine. `ObjectQL` gains `registerHeldFileResolver` (type `HeldFileResolver`), which the storage plugin fills with `findHeldFiles` — the batched form of `findFileHolder`, asking the same union of `sys_attachment` join rows and the `ref_*` ownership columns. Batched because hydration runs over many rows per read: a read with no tombstone costs nothing, and the residual case costs one extra query for the whole read rather than one per file. Engines with no storage plugin keep tombstones un-hydrated exactly as before. +- 8542bd4: **Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + + A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + + What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + + ```js + // this record — nameField, formula or stored column alike + await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); + // a related record, through the lookup column that holds its id + const account = await ctx.title('account_id'); + ``` + + **Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + + **Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + + **It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + + Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. +- 2af5eac: fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277) + + A hook that stripped a field from its input with `delete` did nothing, on BOTH + execution paths, while an assignment made two lines above it on the same object + in the same call landed normally. Nothing raised, and nothing in the platform + reported it. + + Graded `minor` rather than `patch` deliberately: it moves data that reaches + downstream consumers. Any shipped hook that already contains + `delete ctx.input.` has been a no-op until now and starts taking effect + on upgrade — which is the point, and is also exactly why it must not arrive as + a silent patch. No API is removed and no accept set narrows. + + ### The two mechanisms, which were unrelated and produced one outcome + + **In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).** + The flat-record `Proxy` a declarative hook receives over the engine's + `{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` / + `getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell + through to `Reflect.deleteProperty` on the WRAPPER, one level above the record, + removing a key that was never there and returning `true`. `set` was trapped and + wrote into `data`, which is what the engine persists; hence assignment survived + and deletion evaporated. + + **Sandboxed (`applyMutationsToInput`, + `packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations + were written home with `Object.assign(target, result.mutatedInput)`. + `Object.assign` copies own enumerable properties and **has no way to represent a + removal**: a key the VM deleted is simply not in the snapshot, and the host's + key stayed. Deletions are now diffed against the entry snapshot and applied + separately. + + Both are fixed in one change on purpose. Closing either alone would make the + same authored `delete` behave differently depending on whether the hook body + runs in-process or in the sandbox — a worse contract than the symmetric silence + it replaced. + + ### What an author could see, before and after + + The sandboxed path is the one with no tell at all. Measured on the pre-fix code, + one hook call, host row alongside: + + ``` + delete ctx.input.internal_notes -> true + 'internal_notes' in ctx.input -> false <- the VM agrees + Object.keys(ctx.input) -> ['subject'] <- ...and so does this + host ctx.input after write-back -> { subject: 'HELP', + internal_notes: 'STAFF-ONLY' } + ``` + + The in-process path was less deceptive than reported, and the correction is + worth having in writing: only `delete`'s own return value lied there. `'k' in + input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key + as present, so an author who checked with anything other than the return value + would have seen the no-op. + + ### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it + + Found while enumerating the trap set, fixed in the same stroke because it is the + strictly worse shape: it defined on the wrapper, and the `get` trap's + fall-through then read the value straight back — so `input.k` CONFIRMED a write + that never reached `data`, while `Object.keys(input)` denied it and the record + never received it. It now routes into `data` like `set` and `deleteProperty` do. + One inherited JS invariant follows: a proxy may not report success for an + explicitly `configurable: false` descriptor its target does not carry, so + `Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws + a `TypeError` where it used to define, silently and uselessly, on the wrapper. + Omitting `configurable` — the common spelling, and the one spread and + `Object.assign` produce — is unaffected. + + ### The direction the sandbox write-back deliberately does not overreach in + + Absence from the exit snapshot is the only evidence a deletion leaves, and on its + own it is ambiguous: a key whose host value is `undefined` (or a function, or a + symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from + the dump without anyone having deleted it. The diff is filtered through the same + JSON lens the boundary uses, so such a key is left alone. Every failure mode of + that probe is conservative — an unprobeable key is simply not deletable — because + losing a delete is recoverable and destroying a field on evidence that was never + there is not. One residual miss follows and is named here rather than discovered + later: a `bigint`-valued key crosses into the VM as a string but is dropped by the + probe, so deleting one is still lost. + + Measured consumer cost of the reported half: a guest-intake app stripped the + fields an anonymous web-to-case / web-to-lead submitter must not write — + internal staff notes, the resolution, the escalation flag, the owner — with + fifteen `delete` statements, every one inert. A submission carrying + `internal_notes` and `resolution` stored them verbatim, and the app's unit tests + stayed green throughout, because they drive the handler with a plain object where + `delete` genuinely works. +- c34f693: fix(objectql): the flat-input proxy REFUSES a symbol key at `set`/`defineProperty` instead of silently persisting it (#12603) + + **Bump level, argued**: `minor`, not `patch`. Every sibling in this family (#12277, + #12397, #12578, #12601) shipped `patch` because each closed an instrument + DISAGREEMENT — the accepted set of writes never changed, only which read-back + told the truth about them. This card is different in kind: `ctx.input[sym] = + value` and `Object.defineProperty(ctx.input, sym, …)` **used to succeed**, and + now **throw**. That is a narrowing of the accept set on `ctx.input` — a surface + every hook body touches — which is the exact shape `8cc8401` + (`@objectstack/objectql` 17.2.0, "BREAKING (accept-set tightening)") argued + `minor` for under this repo's launch-window convention (pre-1.0 semantics: a + breaking change does not burn a major version while the stack versions in + lockstep — see `scripts/check-changeset-no-major.mjs`). `patch` here would + under-declare a change that can turn a passing hook into a throwing one. + + **What changed.** `installFlatInput`'s `set` and `defineProperty` traps + (`packages/objectql/src/hook-wrappers.ts`) now refuse a symbol-keyed write with + a `TypeError` naming the key kind and the surface, instead of routing it into + the record payload (`data`) the way every string-keyed write is routed. + Measured on the pre-fix tree: a symbol-keyed `set` succeeded silently, the + value reached `data` and persisted to the row the engine stores, and only + `Object.getOwnPropertySymbols` / `Reflect.ownKeys` omitted it from enumeration + — two instruments said "own", enumeration said "no", while the persisted row + held it regardless. + + **Why a refusal, not a fourth instrument fix.** Maintainer ruling, 2026-08-27 + (Option C, refusal arm), on the payload-contract question #12578 measured and + deliberately left open rather than decided: a record payload is a declarable, + **string-keyed** field set — no metadata schema can declare a symbol field, so + a symbol key on this surface is a JS-runtime artifact leaking toward storage, + not a legal payload field. Option B (publish symbols too, via + `Reflect.ownKeys`) was declined — it would have made an undeclarable key kind a + published contract instead of closing the question. Hiding a key the engine + nonetheless persists is precisely the shape #12277/#12397/#12578 exist to + abolish; refusing the write at the boundary closes that gap from the other + side, before persistence rather than after enumeration. + + `ownKeys` itself is **untouched** — still `Object.getOwnPropertyNames(data)`, + exactly as #12578 landed it. With the write refused, `data` can never carry a + symbol key for that trap (or `Reflect.ownKeys`) to disagree about, so there is + nothing left for this card to change there. + + **Migration.** Code that wrote a symbol key onto `ctx.input` — almost always by + accident, e.g. spreading an object that carried a symbol-keyed cache entry onto + the payload — now throws instead of silently losing the write to enumeration. + Use a string key, or keep the value off the payload entirely (a local + variable, or a WeakMap keyed by the record) if it was never meant to be + stored. No other hook-input read/write path changes: reads, `has`, `delete`, + and every string-keyed write behave exactly as before. + + Inverts the pin `hook-input-ownkeys-agreement.test.ts` carried OPEN since + #12578 (the disagreement, deliberately left standing) into a REFUSAL pin + (the write throws, nothing persists) — the same case, turned around in place, + not a second assertion stacked beside the first. + + +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. +- e5ce2ed: Packaged actions can be switched off, on the same activation ledger as flows + + A packaged action can now be disabled for an installation, generalizing the + packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on + the maintainer's amendment ruling 3). The flip writes an install-level row to + the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — + no new table, no new column, no schema change of any kind. Absence of a row + means the packaged default, active, so a deployment that never flips anything + behaves exactly as before, and an empty ledger changes nothing anywhere. + + The consult point is action DISPATCH, and it is present on every door that + dispatches a declared action: the REST `POST /actions/:object/:action` route and + the MCP `run_action` bridge. Both call one shared guard, and a disabled action + is refused `409 ACTION_DISABLED` before anything runs — before the handler body + (which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action + reaches the automation engine, before the param contract is enforced and before + the subject record is read. The refusal names the ledger and the remedies. The + code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and + answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, + which would tell an operator to go looking for a flow that does not exist. + + The consult reads a projection the ObjectQL engine holds and hydrates at boot, + so a disabled action stays disabled across a restart and across the handler + re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — + the ledger records the customer's choice, and nothing re-arms it silently). + + The write door is `POST /actions/_activation/:object/:action` with a + `{ enabled?: boolean }` body. Its first segment is reserved rather than deep in + the path because a machine name can never begin with `_`, so it cannot collide + with an object, an action or a record id. It carries the same two authority + tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture + rule — in the `group` and `isolated` postures the install-wide switch requires + the platform operator, while `single`, where install-level and org-level are the + same scope, is unchanged. That gate is now one implementation shared with + `POST /automation/:name/toggle`; the flow refusal text is unchanged. + + Two refusals are worth knowing about. The ledger addresses an action by its + machine name, so a name declared on more than one object is refused with + `409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off + silently. And a flip that cannot be made durable — no ledger table reachable — + is answered as a failure instead of a 200, because a switch reported as durable + that reverts on the next restart is the failure this whole family exists to + remove. + + Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so + disable is the only primitive here and authoring a new sibling action stays + exactly as it is today. +- 9086761: `FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent). + + Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling. + + There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save. + + `@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch. +- 21196cf: fix(objectql): widen `SchemaRegistry.registerObject`'s `packageId` to optional (#12623) + + **Public-API accept-set widening**, ruled by the maintainer (issue #12623 comment + 5434929046, Option A) — shipped as `minor`: it is not a bug fix in behavior (the + underlying runtime path already treated a missing `packageId` as `undefined` + wherever no `tsc` program enforced the parameter's arity; see below), but it does + change the method's declared TypeScript contract, so it gets a real bump rather + than riding along as an implicit patch. + + `registerObject`'s second parameter, `packageId`, was `string` (required) while its + sibling `registerItem` already declared the identical parameter `packageId?: string` + (optional) — and both feed the same downstream call, + `applyProtection(item, { packageId })`, whose own comment documents the + package-less case as intended: *"bare `registerItem(type, item)` calls without a + package context still produce a clean item."* The mismatch made a supported shape + — `registry.registerObject(schema)` with no package context, used by 82 + single-argument call sites across `objectql`, `rest`, `runtime` and `plugins` — a + type error everywhere a `tsc` program actually read the call (14 sites in + `packages/rest`'s test layer, ledgered as `TS2554` against issue #5286; the other + 68 sites had no gate reading them, so the error was latent rather than caught). + + **FROM → TO:** + + ```ts + // FROM + registerObject(schema: ServiceObject, packageId: string, namespace?: string, ...): string + + // TO + registerObject(schema: ServiceObject, packageId?: string, namespace?: string, ...): string + ``` + + No caller needs to change: every existing call already supplied `packageId` (or + relied on JS's lack of arity enforcement to omit it despite the stricter type), and + the runtime behavior for both cases is unchanged — `packageId?: string` carries + **no default value**. A bare call still passes `packageId: undefined` through to + `applyProtection`, which still leaves the registered item provenance-free (no + `_packageId`, no `_provenance`), exactly as it does today for every already-passing + call site. Pinned in + `packages/objectql/src/registry-register-object-optional-package-id.test.ts`, + which asserts on the registered item's key *absence* (not merely + `=== undefined`), paired with a positive control confirming a call that *does* + pass a `packageId` still gets provenance stamped. + + The exported `ObjectContributor` interface's `packageId` field widens from + `string` to `string | undefined` to match — the exact value `registerObject`'s + own parameter is called with, and the mechanically necessary consequence of the + widening above (`ObjectContributor.packageId` is that value's only home). + + +- a11c1a5: `signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + + - **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. + - **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). + - **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + + Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. +- 5d16379: **BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230). + + `update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes. + + What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict): + + - A truthy scalar `data.id` beside a **non-scalar** `where.id` — an operator object, an array, `null`, or an explicitly-`undefined` `id` key — now **throws** `UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with both declarations silently ignored. + - Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no** `id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm. + - The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered. + + A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations. + + + +### Patch Changes + +- f6fa22c: `min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. + + FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). + + The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. +- fa5d137: feat(devx,datasource,automation): published `src/**` may only import workspace packages it declares (#10062) + + A package's non-test `src/**` was free to import any workspace package, + declared or not, and nothing checked it. The class was filed with one member + and a mitigation — the import was type-only, so nothing reached the emitted + JavaScript and rollup-plugin-dts inlined the declaration rather than naming an + unresolvable module. It grew to four members with no signal, and one of them + killed the mitigation: `service-automation/src/flow-precedence.ts` **value** + imports from `@objectstack/objectql`, which it does not declare, and because + the shared tsup config externalises only `dependencies`/`peerDependencies`, the + bundler answered by inlining objectql's implementation into + `service-automation/dist/index.js` — a second copy of another package's code, + kept correct by build configuration alone. + + `pnpm check:undeclared-dep-imports` is the gate, and the per-member fixes here + are decided one at a time rather than by a uniform policy — declaring makes a + coupling real and installable, routing it away removes it, and the two are not + interchangeable: + + * **`@objectstack/service-datasource`** now declares `@objectstack/driver-sql` + and `@objectstack/driver-memory` as **dependencies**. Both are loaded through + an *unguarded* `await import(...)` on the postgres, mysql, sqlite and memory + arms, so a consumer reaching one of those paths needed a package it was never + told to install, and would have met `ERR_MODULE_NOT_FOUND` rather than a + diagnosis. The three *guarded* driver arms — `@objectstack/driver-sqlite-wasm`, + `@objectstack/driver-mongodb`, `@objectstack/driver-turso` — are deliberately + left undeclared: each load sits in a `try`/`catch` that answers an absent + package with the fault, the consequence and the install command, and each + rides as an optional install. Declaring them would install them (turso drags + `@libsql/client`'s native bindings) and, measured on this branch, takes a live + assertion out of the tree: `default-datasource-driver-factory.test.ts` reaches + the missing-package arm with no stub precisely because the package does not + resolve from here. + * **`@objectstack/metadata-core`** now owns the ADR-0029 D9.6 provenance pair, + `isCodeArtifactBody` and `isTenantAuthored`, sunk out of + `@objectstack/objectql`'s registry by the same criterion as the write-verb + dispatch predicates and the audit governance table beside them: a second layer + needs the answer and the reverse import would either close a cycle or make the + consumer depend on the whole data engine for one predicate. `objectql` + re-exports `isCodeArtifactBody` from its original path, so its public API is + unchanged; `service-automation` imports it from `metadata-core`, which it + already declared, and its bundle no longer carries a copy of objectql's code. + + Two members stay recorded rather than remediated, because the tree already + carries the decision not to declare them together with its reason + (`@objectstack/runtime` → `@objectstack/driver-turso`, whose bare `import()` is + a host-replaceable default thunk under #6268; `@objectstack/rest` → + `@objectstack/objectql`, whose absence must degrade to `501 NOT_IMPLEMENTED` + rather than fail module load). Their ledger rows carry mechanical evidence and + go red the moment that evidence stops holding — in particular, a `type-only` + row reds on the day its import becomes a value import, which is exactly the + transition nothing caught the first time. +- 30d96ab: `ObjectQL.introspectDatasource()` declares its real return type — the spec's `IntrospectedSchema` (the new `IDataEngine.introspectDatasource?` contract member) — instead of an untyped `Promise`, and the driver lookup inside it drops its `as any` now that `IDataDriver` declares `introspectSchema?`. Type-level only; runtime behaviour is byte-identical (#11493). +- 7bd6447: fix(objectql): the flat-input Proxy mirrors `data`'s own descriptor instead of synthesising one (#12397) + + `installFlatInput` hands a declarative hook a flat-record Proxy over the + engine's `{ data, options, id? }` wrapper. Its `getOwnPropertyDescriptor` trap + answered every key `data` carries with one fixed literal — + `{ configurable: true, enumerable: true, writable: true, value: data[prop] }` — + and never read `data`'s real descriptor. For a key created by ordinary + assignment that synthesis is the truth, which is why it cost nothing for as + long as assignment was the only way a key could arrive. + + #12277 routed `defineProperty` into `data`, so a hook can now put a key on the + record payload with non-default attributes for the first time, and the + synthesis reported the defaults back regardless: + + ```js + Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true }); + Object.getOwnPropertyDescriptor(ctx.input, 'k'); // reported enumerable: true — it is not + Object.keys(ctx.input); // …while this correctly omitted 'k' + ``` + + Two instruments over one payload, contradicting each other. The trap now + mirrors `data`'s own descriptor. + + `configurable` is the one attribute that cannot be mirrored: the proxy target + is the wrapper, which does not carry the record key, and a proxy may not report + a property its target lacks as non-configurable — a verbatim mirror throws + `TypeError` on any key `data` holds as `configurable: false`, and takes + `Object.keys` and spread down with it, since both reach every listed key + through this trap. It is forced `true`; `enumerable` / `writable` are mirrored. + + Two further observable consequences, both pinned: + + - Reading a descriptor no longer runs author code. The synthesis evaluated + `data[prop]` to fill `value`, so asking a payload that holds an accessor for + its descriptor invoked the getter; a mirror copies `get`/`set` across + untouched. + - `prop in data` is true for the whole prototype chain, so the synthesis + answered for inherited keys too — `Object.getOwnPropertyDescriptor(input, + 'toString')` returned an own, enumerable, writable data property no payload + has ever held, and `Object.hasOwn(input, 'toString')` was `true`. Only an own + key has a descriptor to mirror; inherited keys now report `undefined`, while + `'toString' in input` and the read itself are unchanged. + + Enumeration is untouched: `ownKeys` still lists exactly `data`'s own enumerable + keys and the mirror reports those as enumerable, so `Object.keys`, spread, + `Object.entries` and the sandbox's `unwrapProxyToPlain` see byte-identical + results. What a record payload may hold, how `defineProperty` routes into + `data`, and how the engine persists it are all untouched. +- 86df0c9: fix(objectql): the flat-input Proxy's descriptor trap agrees with `get` about the four reserved names (#12601) + + `installFlatInput` gives `id` / `options` / `ast` / `data` precedence in the + `get` trap — a direct read (`ctx.input.id`) always resolves against the + engine's `{ data, options, id? }` wrapper (the envelope), never against the + record payload, even when the payload itself declares a field sharing one of + those names. `getOwnPropertyDescriptor` checked the payload FIRST instead, so + for an update whose payload happened to carry a same-named field: + + ```js + const raw = { data: { id: 'PAYLOAD-ID', subject: 'help' }, options: {}, id: 'WRAPPER-ID' }; + ctx.input.id // 'WRAPPER-ID' (get) + Object.getOwnPropertyDescriptor(ctx.input, 'id').value // 'PAYLOAD-ID' (descriptor, pre-fix) + ``` + + Two instruments over the identical key, contradicting each other — and + anything that copies a value out of a raw descriptor rather than through + `get` inherited whichever one this trap picked. + + Per the maintainer ruling on #12601 (Option A — "the envelope wins + consistently"): the four names are reserved on the hook flat-input face. + `getOwnPropertyDescriptor` now checks them first, exactly where `get` already + does, so a descriptor read can never again disagree with a plain read of the + same key. A payload field sharing one of the four names stays a legal record + field — it round-trips through storage unchanged — it is simply not reachable + through the flat face; `ctx.input.data.` is the only route to it. + + ## Why `patch`, argued rather than assumed + + This is the same trap set, the same shape of fix, and the same scope as the + two immediately preceding fixes here — #12397 (descriptor trap mirrors `data` + instead of synthesising) and #12578 (`ownKeys` reports the payload's own key + set) — both shipped `patch`, and both changed what a specific instrument + reports for specific inputs, exactly as this one does: + + - **No persisted data moves.** Unlike #12277 (`delete` actually deletes, + shipped `minor` because it changes what lands in storage), this fix changes + only what a READ instrument reports; the row the engine writes is byte-for- + byte identical before and after. + - **The paths that already worked stay byte-identical.** `ctx.input.id` + itself (`get`), `Object.keys`/spread/`Object.entries` + (`ownKeys` + the descriptor's `enumerable` flag + `get`'s value) all + already answered the envelope's value for a reserved name before this fix — + measured, not assumed (see the test file). The only caller this changes is + one that reads `Object.getOwnPropertyDescriptor(ctx.input, '').value` directly, on a payload that ALSO happens to declare a field + by that exact reserved name — a combination narrow enough that it is the + disagreement itself, previously unnoticed, that this card exists to close. + - **It restores an invariant the code already claimed to hold** (`get` and + the descriptor trap were always supposed to agree — that is the whole + premise of a "flat view"), rather than adding or removing a capability. A + hook body that worked correctly before this fix — i.e. one that never + happened to hit the exact disagreeing combination — is unaffected. + + Not declared breaking, so no ADR-0087 disposition marker applies (this + changeset removes/renames no authorable spec key, export, or config field — + `check-adr-0087-registration.mjs` only judges changesets that declare a + breaking/major change). +- 5a22dd7: fix(objectql): the flat-input proxy's `ownKeys` reports the payload's own key set, not its enumerable subset (#12578) + + `installFlatInput` answered the `ownKeys` trap from `Object.keys(data)` — own **enumerable + string** keys. That filtering was incidental to what the trap is for (hiding the wrapper keys + `id`/`options`/`ast`/`data` from `Object.keys`/`for…in`), and it cost a key: an own + **non-enumerable** key on the record payload was absent from `Object.getOwnPropertyNames(input)` + and `Reflect.ownKeys(input)` while `hasOwnProperty` and the descriptor trap both reported it — + and while the engine persisted the row holding it. Measured on the merged ref, for a payload + `{ subject }` a handler had added `k` to with + `Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true })`: + + ``` + Object.getOwnPropertyDescriptor(input, 'k') -> own, enumerable:false + Object.prototype.hasOwnProperty.call(input, 'k') -> true + Object.getOwnPropertyNames(input) -> ['subject'] <- not own? + Object.getOwnPropertyNames(persisted row) -> ['subject', 'k'] + ``` + + Three instruments, one payload, two answers about own-ness. Newly reachable rather than newly + written: #12277 routed `defineProperty` into the payload, so a handler can put a + non-default-attribute key there for the first time, and #12397 made the descriptor trap mirror + the payload instead of synthesising defaults — which is what gave the third instrument an + opinion to disagree with. + + The trap now reports `Object.getOwnPropertyNames(data)`. **The enumerable face is unchanged**: + `Object.keys`, spread, `Object.entries`, `for…in` and `JSON.stringify` still omit a + non-enumerable key, because each applies the `enumerable` filter itself, one layer up, through + the descriptor trap. Applying it inside `[[OwnPropertyKeys]]` as well did not make those answers + cleaner — it only starved the two surfaces whose entire job is to report the whole set. The + sandbox body face is byte-identical for the same reason: `unwrapProxyToPlain` + (`@objectstack/runtime`) snapshots `ctx.input` as `Object.entries` over this proxy. + + Wrapper keys stay excluded, which is the trap's purpose — achieved by reading `data` and never + the wrapper, not by subtracting those four names, which would hide a genuine payload field named + `id`. **Symbol keys remain unenumerated**: they already reach the payload and already persist, so + publishing them through `ownKeys` is a question about what a record payload may hold rather than + about this trap, and it is left open on #12578 rather than decided here. + + Pinned in `hook-input-ownkeys-agreement.test.ts` as the AGREEMENT of the three own-ness + instruments — not as one trap's output, which is the pin shape that let the halves diverge — with + the wrapper-key and symbol exceptions pinned as deliberate exceptions. Reverse-verified by + ablation: restoring `Object.keys(target.data)` fails exactly 2 of the 6 new cases (32 of 34 green + across the four hook-input suites), and the enumerable-face assertions stay green under the + mutation, which is what proves that half untouched. +- d395692: Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two + introspection declarations to the measured emitted types (#11122, maintainer + ruling 2026-08-23, option B — 「其他同意你的意见」). + + The spec's introspection contract (`schema-diff-service.ts`) declared + `indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted + it — a consumer typed against the promise read `undefined` with no compiler + complaint. It also declared `defaultValue?: string` while the in-tree SQL + driver passes `knex.columnInfo().defaultValue` through raw (measured on live + SQLite: `null` for a column with no default, dialect-quoted strings such as + `'abc'` otherwise; other producers report native values such as `true`). + + - `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: + an absent key means the producer did not read indexes; an empty array is a + positive claim the table HAS none. Producers that did not look must omit + the key rather than emit `[]`. Wiring the index read into + `introspectSchema()` is explicitly NOT part of this change. + - `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow + before use instead of trusting a string promise no producer kept. + - The SQL layer's extra `maxLength` fact (driver-sql / objectql + `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to + `number | string` — SQLite reports the string `"255"` where other dialects + report a number. + + With the spec now telling the truth, the deliberate `Omit` workarounds in + `@objectstack/driver-sql` and `@objectstack/objectql` (which carved + `defaultValue` and `indexes` out of the spec types to keep the divergence + visible) are retired: both packages' introspection types now extend the spec + contract directly. + + Consumers that read `table.indexes` must guard for absence (none exist + in-tree — the requirement was never honored, so today's readers would have + crashed on `undefined` anyway); consumers of `defaultValue` must narrow from + `unknown` before string operations. +- bd0c5cc: fix(objectql): a failed `sys_organization` tenant scan no longer decides a retention window (#12853) + + `LifecycleService.loadGovernance()` filled `snapshot.tenantOverrides` — the + ADR-0057 §3.2 per-tenant retention/expiry window set — behind a bare `catch {}` + whose comment named ONE benign cause ("No sys_organization (single-tenant + kernel)") while the `catch` swallowed every cause. On a connection drop, a + timeout, a permission refusal or a driver fault the map came back EMPTY, and an + empty map is not a neutral value: `reap()` and `archiveObject()` read it as + "this deployment has tuned no tenant" and fall every tenant back to the global + window. That window is wrong in both directions, and the expensive direction is + a tenant configured to retain LONGER having its rows expired early. Nothing + reported it: `GovernanceSnapshot` carries no field saying the tenant pass did not + complete, and the catch logged nothing — so the platform deleted on knowingly + incomplete evidence, without knowing the evidence was incomplete. + + The scan now discriminates by error TYPE through the shared + `isMissingTableError` predicate. An unprovisioned `sys_organization` really does + mean "no tenant overrides", so a single-tenant kernel is unchanged. Every other + cause aborts the sweep **before any policy is applied** — for a deletion action, + "do not act on incomplete evidence" is the correct failure direction, and a log + cannot bring back a reaped row. The rows a deferred sweep leaves are still there + for the next one. + + Operational posture change, deliberate and worth stating: a transient + `sys_organization` outage now costs a sweep. The abort is REPORTED, not thrown — + one `report.errors` entry per declared object plus a `warn` — because `sweep()`'s + declared contract is that it never throws and the scheduler enters it as + `void this.sweep()`, where a rejection would be unhandled. That is the same + objection #8906 recorded when it declined to rethrow from `checkGovernance` one + method below. + + Bump argued, not defaulted: `patch`. No exported signature, type, option or + report field moves — the failure surfaces through `LifecycleSweepReport.errors`, + which already exists for exactly this. The tension is honest and does not change + the answer: what a deployment observes on a failure path DOES change (a sweep + that used to complete silently now aborts and says so), but that is the + correction of a defect, not a new capability, and the sibling repairs in this + family (#8896, #8906, #9817) all shipped as `patch`. +- c5b9ccc: **Fix:** the engine's three privileged driver-level reads now JOIN an open ambient transaction instead of asking the connection pool for a second connection — which deadlocked `pool max=1` datasources and made `/admin/remove-user` refuse an entitled, signed-in caller with `401 UNAUTHENTICATED` (#10792). + + `resolveSecret`, `resolveSecretField` and `resolveInternalField` read at DRIVER level on purpose: that is the only layer where a masked or `internal: true`-omitted value still exists, and bypassing hooks, field-level security and sharing is the declared trust each of them places in its in-process caller. What they also bypassed — not by design — was the connection the surrounding transaction is holding. `buildDriverOptions` threads the ambient handle (ADR-0034) onto every ordinary read for exactly this reason; these three passed the driver **no options at all**, so their read went to a *fresh* pooled connection. + + On a roomy pool that is invisible: the pool simply hands out another connection. On a single-connection pool it is a deadlock. SQLite's knex pool is `max: 1` — `driver-sqlite-wasm` and `driver-sql`/better-sqlite3 both — and `pool max=1` is not a tuning choice there, it encodes SQLite's single-writer model. + + Measured on the erasure path, which is where the two met. `AuthManager.handleRequest` runs the `SESSION_ERASURE_PATHS` routes inside `engine.transaction(...)` so a refused erasure cannot leave the session and account deletes committed. Inside that transaction the vendor's session re-read reaches `resolveInternalField` through plugin-auth's internal-field readback; the read waited for a connection that could not be freed until the transaction waiting on the read finished, knex's acquire timeout fired (`Timeout acquiring a connection. The pool is probably full`), and the route degraded the block into an authentication refusal. On the default `objectstack dev` datasource, before this change: a caller better-auth's own admin gate **admits** was answered `401` after **120,196 ms** with the target row still present, and a signed-in plain member got the same `401` after **120,025 ms** instead of the `403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` an authorization refusal owes them. After: `200` with the row deleted, `403`, and an anonymous caller's `401` unchanged — all promptly. Postgres and MySQL (`max >= 10`) always conformed and are unaffected; the reach nonetheless mattered because SQLite is the default datasource for `objectstack dev`, the showcase/dogfood boot, and any self-host that has not configured Postgres or MySQL. + + Two properties are deliberately **not** widened. The join is reads-only — the privileged write paths are untouched. And the #5351 same-origin gate still decides whether the handle is this object's driver's to use, so a privileged read that resolves to a *different* datasource keeps its own connection rather than executing someone else's statement on the wrong one. +- fe72aa5: fix(objectql): the `[Registry] Collision` warning fires in the cold-boot order too (#12027) + + The artifact-vs-DB collision warning was order-asymmetric, and silent in the + order a kernel boot actually produces. It was guarded on `packageId &&`, so it + spoke only when the PACKAGE registered second — but the artifact reaches the + registry in kernel Phase 1 (`AppPlugin.init` -> `manifest.register`) and the + `sys_metadata` overlay is rehydrated in Phase 2 (`ObjectQLPlugin.start` -> + `loadMetaFromDb`), under the bare name with no package id. The kernel runs + init-all then start-all, so at boot the overlay is ALWAYS the second arrival — + the exact order the guard excluded. The direction that did warn is the + late-registration one: a marketplace install, a post-`start()` + `manifest.register`, an HMR reload. + + The consequence is worse than a missing line, because the mechanism looked + sound to anyone who had seen it work: ADR-0005 says this warning is what makes + the silent shadowing "discoverable in startup logs", and in the only order + startup produces it was not discoverable at all. Measured on a real + `@objectstack/example-crm` boot before the fix: one stored `view` overlay of a + packaged view produced 0 collision lines and 4 silent shadowings (the container + plus its three expanded ViewItems). + + The cold-boot direction now warns with its own message rather than a widened + version of the existing one. Both orders end in the same state — the runtime + row wins either way — but the event differs, and the event is what an operator + acts on: a package that is dead on arrival behind a row that predates it, + versus a stored row taking over a definition this process just loaded from + code. Which definition wins is unchanged in both orders, and pinned as such. + + Graded `patch`: this adds a diagnostic to a path that printed nothing. No API + changes, no accept/reject behaviour changes, and resolution order is untouched. + The one operator-visible effect worth stating is the log itself — a deployment + that customizes packaged metadata will see one new `[Registry] Collision` line + per shadowed name per process, where it previously saw none. Volume was + measured rather than assumed: 0 lines on a stock boot (a stock `sys_metadata` + holds no overlay of a packaged name), and the line marks the transition into + the bare slot rather than the state, so the read-side hydration and the + write-through do not re-emit it on later reads and writes. +- bc5156f: fix(objectql): single-quote the shadowed package id in the `[Registry] Collision` warning (#12609) + + `patch`: a shipped operator-facing log message changes its punctuation, and + nothing in this repo parses the message beyond substring (`toContain`) test + assertions — no regex or char-level match on the quote character was found + (searched non-test `.ts`/`.tsx`/`.js` across the repo, and `content/`/`docs/`, + for the message text; the two other hits are prose references to the warning + by name, not parsers of its text). No public export, type, or behavior + changes. + + ## What changed + + `SchemaRegistry.registerItem`'s cold-boot-order `[Registry] Collision` + warning (`packages/objectql/src/registry.ts`) double-quoted the shadowed + package id — `` `... is shipped by package "${shadowed._packageId}" ...` `` — + against this package's own convention: measured over non-test `.ts` under + `packages/objectql/src`, quoted identifiers in operator prose are + single-quoted 174 times against 37 double-quoted, and this line was one of + the 37. #12563 already settled the same ADR-0005 shadowing fact on the + automation side (`service-automation`) with single quotes + (`package 'crm'`), so an operator whose boot hits both packages' collision + warnings previously read one story in two spellings; this line now matches. + + Byte-identical otherwise. Only the quote character around the interpolated + package id moved. + + ## Scope + + One message, one site + (`packages/objectql/src/registry.ts`'s `is shipped by package` warning — + the guard that fires in the common cold-boot order, package registers + first). A sibling `[Registry] Collision` warning in the same file + (`ships from package`, the late-registration-order guard) also + double-quotes its package id and is the same defect class, but is a + different message and a different site — out of scope here, filed + separately rather than swept in. + + ## Test + + No existing pin held this message's literal quote character (`toContain(PKG)` + assertions in `registry-collision-order.test.ts` matched either spelling); one + is added — asserting the corrected spelling present **and** the pre-fix + spelling absent, so it is red in both directions, not just green on the fix. + + +- 5700d83: fix(objectql): correct `mergeObjectDefinitions`'s docblock to the real, closed merge set (#12680) + + The docblock on `mergeObjectDefinitions` (`packages/objectql/src/registry.ts`) + said: + + > Fields are merged additively. **Other props: later value wins.** + + The implementation has never done the second half. It merges exactly: + `fields` (additively), `validations` (additively), `indexes` (additively), + and the three guarded scalars `label` / `pluralLabel` / `description` + (last-writer-wins, subject to the `tenantAuthored` yield rule). **Every + other top-level prop an `extend` contributor carries is silently + discarded** — `merged` starts as `{ ...base }` and nothing outside that list + is ever copied onto it. + + This is a **docs-only correction plus a regression pin — zero runtime + behaviour changed.** `mergeObjectDefinitions` is not exported; nothing about + what it does was touched, only what the comment above it claims. Shipped as + `patch` rather than omitted because the file's behaviour is now backed by an + enforced pin (see below) where before it was backed by nothing but an + inaccurate comment — that is a real (if internal-only) improvement to the + package worth a version bump, and this repo's convention reserves "no + changeset" for changes with no user-facing effect of any kind, not for + "no runtime diff." There is no public API surface to widen or narrow (the + function is module-private), so there is nothing here for `check:*` gates + that watch exported shapes to see. + + Why this matters: an author reading the old docblock and shipping an + `objectExtensions` entry carrying, say, `tenancy: { enabled: false }` would + have gotten a **silent no-op** on a security-relevant key — no error, no + warning, the base's existing value simply wins as if the extension had never + named the key. That near-miss is the reason this card exists (found while + resolving cloud#1653's investigation into exactly that override path). The + corrected docblock says the discard out loud; a new pin + (`registry-object-extension-nonenumerated-prop-discard.test.ts`) hands + `mergeObjectDefinitions` (via the public `SchemaRegistry` API) an `extend` + contributor carrying a non-enumerated top-level prop and asserts the merged + result does not carry it, with a guarded scalar as a positive control in the + same fold. `icon` is the pin's fixture — a real, spec-legal, security-neutral + top-level prop — deliberately **not** `tenancy`, so the pin does not read as + license to special-case that key elsewhere. + + ⛔ Out of scope, explicitly: implementing "later value wins" for the + undocumented remainder (making `tenancy` / `permissions` extender-writable is + a separate, much larger decision triage fenced off this card) and adding a + runtime warning on the silent drop (a real question, filed separately rather + than folded in here). +- aa0688a: Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name + + A runtime-authored flow that reused a packaged flow's name silently replaced it, + and which of the two ended up armed depended on registration order. The metadata + registry keys items `packageId:name` and deliberately coexists both (ADR-0048 + §3.4), `listItems('flow')` returns both with no precedence, and the automation + engine keys flows by bare name — so the boot pull registered both under one key + and Map iteration order picked the survivor. Measured: registering the package + first armed the runtime flow, registering the runtime row first armed the + packaged flow, with no warning and no way to tell which had won. + + The boot pull now collapses same-named definitions before anything is armed, + applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the + runtime/DB overlay wins over the packaged artifact, which is the sanctioned + override path. Two packages shipping one bare name resolve by package id, so + boot order no longer decides anything. + + Collisions are no longer silent. The pull warns once per colliding name — naming + the name, every contender, and which one is armed — and repeats it at bootstrap + beside the other automation audits. `getShadowedFlows()` is a new receipt listing + each contested name with its armed and shadowed definitions, and + `getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested + names; previously the displaced definition was invisible by construction, since + the flow map holds one entry per name. The `Pulled N flow(s)` line now counts + distinct names rather than registrations. + + `isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that + collapse same-named metadata answer "does a code package ship this?" with the + registry's own test instead of re-deriving it from `_packageId`. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [32448d4] +- Updated dependencies [54e2d36] +- Updated dependencies [7ef0268] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [f93df4d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [376c70f] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [78f65ef] +- Updated dependencies [901355c] +- Updated dependencies [f4e7ae5] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [33b52fe] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [457ff75] +- Updated dependencies [ece4dad] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [bd0c5cc] +- Updated dependencies [9a884c6] +- Updated dependencies [1cba33f] +- Updated dependencies [da1126a] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-protocol@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 23dd18dee2..9b3d816f39 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.2.0", + "version": "17.3.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 fd6e747cdb..2e230c4991 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,194 @@ # @objectstack/observability +## 17.3.0 + +### Minor Changes + +- 37e82eb: Surface a licensed `max_nodes` oversell to operators as telemetry + + `os serve` already warned loudly at boot when `OS_CLUSTER_REPLICAS` declared more + nodes than the licence gate admits, but that warning existed only in one + process's startup output: an operator who scaled past their cap three weeks ago + had no way to ask the question today, and no way to alert on it. The same + advisory verdict is now also published through the deployment's configured + metrics backend, so it reaches the place operators already look. + + Three names join `SEMCONV` in `@objectstack/observability`, emitted once per boot + by `os serve` when a remote cluster driver is configured, each labelled with the + gate's own verdict vocabulary (`admitted` / `capped` / `refused`): + + - `cluster_declared_nodes` (gauge) — the replica count the operator **declared**; + - `cluster_admitted_nodes` (gauge) — how many of them the licence **admits**; + - `cluster_node_cap_verdicts_total` (counter) — one increment per process boot + that consulted the gate, so an alert stays writable after a one-shot gauge has + aged out of a push-based backend. + + **Visibility only — the cap remains advisory and nothing is refused.** The gate is + consulted once per process at boot, every replica computes the same verdict, and + none can know whether it is one of the admitted ones, so all of them still join. + The names say so on purpose: this process has no cluster membership view at all, + so a series called `cluster_nodes` or `cluster_active_nodes` would be a false + statement dressed as telemetry. Nothing here counts peers, and no accept/reject + behaviour changed. + + Absence is meaningful rather than an instrumentation gap: a single-node + deployment never consults the gate and emits nothing, and an emission also needs + a metrics backend configured via `OS_OBS_EXPORTER`. + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index ae1f5cc9f0..bc216315bc 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.2.0", + "version": "17.3.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 e79f4f1558..905bb7753e 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,835 @@ # @objectstack/platform-objects +## 17.3.0 + +### Minor Changes + +- 09b4f4e: feat(platform-objects,cli): record which source revision a generated translation leaf was filled from (#11671) + + Closes the half of the sticky-translation-drift class that no value comparison + could reach, under maintainer ruling #12069 Option A — by extending the existing + #8765 Option B source-hash mechanism to the generated bundles rather than + building a second one. + + **The hole.** `os i18n extract --fill=default` fills gaps only: any non-empty + value in a translated locale wins forever. So the ordinary sequence — extract, + revise the source string, extract again — rewrites `en` and strands the previous + source text in every other locale. The bundle is still in sync by key, so + `check:i18n` reports OK; the leaf is still present, so `check:i18n-coverage` + counts it translated. Measured on #11659 at `bbe0b17`: three locales serving a + 602-char superseded draft of a 411-char help string under 31 green checks. Once + the source has moved, that stale fill is indistinguishable **by value** from a + real translation — 2648 of 3010 leaves differ from `en`, so "untranslated AND + differing from the source" describes an empty set, not a noisy one. + + **What is new.** `os i18n extract --source-hashes` writes + `.source-hashes.generated.ts` beside each generated bundle: per leaf, + the digest of the source revision that leaf is **still a byte copy of**. + `withSourceFallback` takes that table as a fourth argument and now judges the + `objects` / `metadataForms` sections as well as the hand-authored ones, so a + leaf whose source has moved underneath it serves the current source string + instead of a superseded draft — the same degradation an untranslated key already + produces, which is the invariance the #8765 ruling turned on. + + The generated half needs one conjunct the hand-authored half does not: the leaf + must still hold the recorded bytes. Its hash table is itself generated, so a + translator cannot be asked to refresh a digest by hand the way + `.source-hashes.ts` asks; without that conjunct, re-translating a stale + leaf would leave the old record standing and report the fresh translation as + stale forever. With it, editing the value clears the flag by itself. + + **Behaviour on the day it lands: unchanged for every leaf.** Records are + written only where a leaf is currently a byte copy of the **current** source, so + every record equals the current digest and nothing is stale. Measured across the + nine bundle sets: 9030 translated leaves, 1543 byte-equal to `en` (records + written), 7487 differing (left with no record — legacy-trusted, per the ruling's + property 1, since nothing in the tree says which revision they were made from). + No committed bundle changed a byte. + + **Scope.** `--source-hashes` is off by default and `@objectstack/platform-objects` + is the one bundle set that opts in, by documenting the flag in its extract + config. The other eight sets keep exactly today's behaviour and can be enabled + file-by-file later; a set with no companion is entirely legacy-trusted. + + The false "this hole cannot occur there" note that kept the generated sections + out of the mechanism is corrected in `source-hash.ts`, with the measurement that + falsifies it. +- 3954fb7: Declare sourced `maxLength` bounds on the thirteen unbounded keyed identity + columns, so their declared indexes can exist on MySQL + + `driver-sql` (since #11430) honours a keyed text-family field's declared + `maxLength`, emitting `varchar(maxLength)` instead of `TEXT` — but thirteen + identity columns declared no bound at all, so on MySQL every one of their + declared indexes was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB + column cannot be a key without a prefix length) and the objects landed + registered-but-broken. Measured on live MySQL 8.0.46 (`+08:00`, + `STRICT_TRANS_TABLES`): schema-sync failures drop **12/44 → 8/44** platform + objects and physically-present declared indexes rise **89/128 → 104/128**, + with the Postgres 16 control at 0 failures on both legs. `sys_session`, + `sys_api_key`, `sys_device_code` and `sys_oauth_consent` sync completely + clean — a MySQL stack can now enforce the session-token uniqueness its + sign-in path assumes. + + Every bound is derived from a named source, none guessed (maintainer ruling + on #11374, 2026-08-24 — route A; the full table with sources is in the PR): + better-auth 1.7.1's own MySQL schema mapping (`session.token` / + `verification.identifier` → 255), its device-authorization plugin's hard + runtime cap of 191 on both codes, IdP norms (`account_id` 256 = SAML Core + NameID cap, above OIDC Core's 255 `sub` cap), the landed bounds of referenced + or producing siblings (`client_id` × 4 → 255 from + `sys_oauth_application.client_id`; `provider_id` 255 from + `sys_sso_provider.provider_id`; `issuer` 2048 from `sys_sso_provider.issuer`), + and the in-repo producer (`sys_api_key.key` 64 = fixed sha-256 hex). + + This is an enforcement change on published objects — hence the minor grade: a + write wider than its column's new bound is now **refused** (measured: a + 300-char `sys_session.token` insert fails `ER_DATA_TOO_LONG` on a strict + server, 0 rows; a 255-char one lands). Every bound admits everything its + upstream producer can write, so only values the producing contracts already + forbid are affected. + + Deliberately not bounded, per the ruling's escape clause: + `sys_verification.value` (better-auth's oauth-provider stores JSON + authorization-code payloads there — no defensible bound exists), and + `sys_import_job.created_by` (outside this card's identity surface). + `sys_account.issuer`'s 2048 exceeds the 768-char utf8mb4 key ceiling on + purpose — tighter would refuse SSO sign-ins that `sys_sso_provider`'s own + contract admits — so its `(issuer, account_id)` unique stays for #11627's + hash-shadow route, alongside the `maxLength: 1024` token columns. A new pin + test enumerates every keyed text-family identity column and names any future + unbounded arrival. +- 4805b56: Declare a sourced `maxLength` on `sys_import_job.created_by`, so its declared + index can exist on MySQL — route A's last column + + `driver-sql` (since #11430) honours a keyed text-family field's declared + `maxLength`, emitting `varchar(maxLength)` instead of `TEXT`, and #11699 + declared bounds on thirteen keyed identity columns. `sys_import_job.created_by` + is keyed by `(created_by, created_at)` and declared no bound at all, so on MySQL + that index was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB column cannot + be a key without a prefix length) and the object landed registered-but-broken. + It was the only remaining such object outside the >768-character class that + #11627 tracks. + + The bound is **255**, derived by referenced-column transitivity rather than + chosen: the column holds a `sys_user.id` stamped by the rest-server import route + from `context.userId`, and `driver-sql` creates every table's primary key as + `table.string('id').primary()` — knex's `varchar(255)` — so no id this column + can receive exceeds 255. It agrees with what the column would get if declared + like its siblings (`Field.lookup('sys_user')` emits + `DEFAULT_STRING_VARCHAR_CHARS` = 255) and with the landed declarations for the + same value class (`sys_metadata_audit.actor`, `sys_metadata_commit.actor`, + `sys_view_definition.owner`, all 255). A minted platform id is 26 characters, so + the bound clears the floor with 229 characters of headroom. + + This is behaviour-narrowing on a published object: on a strict MySQL server a + `created_by` longer than 255 is now **refused** (`ER_DATA_TOO_LONG`, 0 rows) + rather than stored, where previously the column was unbounded `TEXT`. No value + the producing contract can emit is affected, because the id it copies is itself + capped at 255 by its own column. + + The route-A pin moves from `identity/identity-keyed-text-bounds.test.ts` to + `platform-keyed-text-bounds.test.ts` and now enumerates **every** platform + object the package exports, not just `identity/`. That directory scoping is + exactly how this column escaped the first pass — the pin could not see it — and + a new control asserts the enumeration reaches columns in `audit/`, `metadata/` + and `system/` so the narrowing cannot silently return. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. +- f6344e7: Make the last two non-unique keyed text indexes expressible on MySQL — remove one, + narrow one + + `driver-sql` emits a keyed text-family column as `varchar(maxLength)` only when the + declared bound is one MySQL can key (768 characters on utf8mb4, the 3072-byte key-part + ceiling); otherwise the column stays `TEXT`, MySQL refuses it as an index key + (`ER_BLOB_KEY_WITHOUT_LENGTH`), and the object's whole `syncSchema` fails — it lands + registered with its declared index absent. #11374 declared sourced bounds for thirteen + such columns and #11627 carried the over-long UNIQUE ones on a SHA-256 hash-shadow + column, taking live MySQL 8.0.46 from 12/44 → 8/44 → 2/44 failing objects. + + The two that remained are **non-unique**, and a hash shadow structurally cannot serve + them: a UNIQUE constraint is an equality-only predicate that survives hashing exactly, + but a non-unique index exists for an access path, and an index over a digest + accelerates no `WHERE col = ?` the planner can reach without rewriting the read side. + They are ruled separately (maintainer, 2026-08-25) because they are different problems: + + - **`sys_verification.value` — the declared index is removed.** The column is + genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code + payloads there as a JSON blob), and the index was measured dead: better-auth 1.7.1 + keys every verification lookup on `identifier`, `id` or `expiresAt` + (`internal-adapter.mjs`), upstream declares the field unindexed and unbounded, and no + in-repo query filters `sys_verification` by `value`. An index that silently does not + exist on one dialect is the worst of both worlds; removing it makes the metadata match + reality. + - **`sys_oauth_client_resource.resource_id` — the declared bound narrows 1024 → 768.** + This one is a live access path (the FK side of `sys_oauth_resource.identifier`, read + as a predicate by upstream's client-registration collision path), so it keeps its + index and becomes keyable instead. 768 is the widest utf8mb4 value a MySQL key part + holds, and the smallest narrowing that works. + + This is an enforcement change on published objects — hence the minor grade. On MySQL and + SQL Server a `resource_id` longer than 768 characters is now refused rather than stored, + and on PostgreSQL and SQLite the `sys_verification` `[value]` index is dropped on the + next schema sync (on MySQL it never existed). Neither narrows what the producing + contract can emit: the value is an RFC 8707 resource-indicator URI, and upstream + better-auth 1.7.1 stores that same identifier as `varchar(255)` on MySQL + (`get-migration.mjs`) and this referring column as `varchar(36)`, so a resource whose + identifier exceeded 768 characters could never have been registered upstream at all. + + The pin that enumerated the package for unbounded keyed text columns now also rejects a + non-unique index over any text column MySQL cannot key, so a third member of the class + fails at test time rather than on a live server. Its `UNBOUNDABLE` allowlist — which + existed to excuse `sys_verification.value` — is empty as a result, and a synthetic + control keeps the excusing branch exercised rather than letting it rot. +- d79c602: fix(platform-objects): source `sys_oauth_resource.identifier`'s bound from its producer — 1024 → 255, and the referring column with it (#12313) + + **BREAKING** accept-set narrowing on two published objects, shipped as `minor` + under the repo's launch-window convention for breaking changes. + + + + `sys_oauth_resource.identifier` declared `maxLength: 1024`. That number cited no + producer — it arrived with the object wholesale (#3080) as generous slack for + "a URI". Every other bound in the #11374 family names where it came from; this + one had no comment at all. + + Measured, the producing contract cannot fill it. better-auth 1.7.1 is the sole + writer (`managedBy: 'better-auth'`, `protection.lock: 'full'`) and emits this + column as **`varchar(255)`** on MySQL: `oauthResource.identifier` is declared + `{ type: 'string', required: true, unique: true }`, and `getType` in + `better-auth/dist/db/get-migration.mjs` takes the `field.unique → 'varchar(255)'` + arm of its mysql string branch. Verified by running that generator against live + MySQL 8.0.46 and reading `information_schema.COLUMNS` as its own query: + `varchar(255)`, 1020 octets under utf8mb4. + + **The dead end this closes.** #11701 had already narrowed the REFERRING column + `sys_oauth_client_resource.resource_id` to 768 so its declared index could exist + on MySQL at all. The two halves of one foreign key then disagreed about what a + legitimate resource identifier is. On PostgreSQL or SQLite — neither of which + has MySQL's key-width ceiling — an operator could register a resource whose + `identifier` was 900 characters, because the referent's contract admitted it, + and then no client could ever be granted that resource, because the referrer + refused it. Registration succeeded, authorization failed forever, silently. + Both columns now declare **255**, so referent and referrer admit exactly the + same domain. + + **What the narrowing rejects.** Values in **(255, 768]** move from "the referrer + accepts" to "both refuse"; values in (768, 1024] were already refused by the + referrer and are now refused by the referent too. Nothing upstream can produce + either band — the sole writer stores the identifier in `varchar(255)`. + + **Hash-shadow outcome, measured rather than predicted.** 255 × 4 = 1020 bytes + sits under `SqlDriver.MAX_KEYABLE_VARCHAR_CHARS` (768 characters / 3072 bytes), + so `sys_oauth_resource` **LEAVES** the #11627/#12198 hash-shadow route it was on + at 1024. Both readings are from `information_schema` on live MySQL 8.0.46: + + | | before (1024) | after (255) | + |---|---|---| + | `identifier` physical | `text` (65535 octets) | `varchar(255)` (1020 octets) | + | shadow column | `uniq_sys_oauth_resource_identifier__hash varbinary(32)` present | **absent** | + | UNIQUE index keys on | the shadow column | `identifier` directly, `SUB_PART NULL` | + + The declared uniqueness is unchanged and still enforced over the full value — + the index is a direct full-value UNIQUE, not a prefix index. Deployments that + already synced this table on MySQL will see the shadow column dropped and the + UNIQUE index rebuilt directly on the narrowed column at the next schema sync. + + **A correction to the #11701 citation.** That comment stated upstream emits the + referring column as `varchar(36)` via `getType`'s `field.references` arm. It + does not: `resourceId` participates in table-level indexes, so `getType` + receives a `tableIndexStringLength` argument, which takes precedence over every + `field.*` arm, and `getDatabaseIndexStringLength` seeds its reduce at MySQL's + 191-character default — measured, upstream emits **`varchar(191)`**. That 191 is + an artifact of upstream's index budget on upstream's own physical schema; + ObjectStack emits its own schema, so the referring column takes the REFERENT's + 255, the same derivation `client_id` already uses. +- 4d25d22: **BREAKING (platform object removed):** the `sys_scim_provider` platform object is retired (#11757, ruled on #11693 — leg 1a of the #11632 SCIM epic). + + FROM → TO, per surface: + + - `SysScimProvider` (export of `@objectstack/platform-objects` / `.../identity`) → removed, no replacement export. Fix: delete the import. Stable SCIM state lives on the seven `sys_scim_*` stable-model objects (#3653), and connection credentials on `sys_scim_connection_credential`. + - `sys_scim_provider` in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) → removed. `isPlatformProvidedObjectName('sys_scim_provider')` is now `false`, so a stack referencing the name is flagged as a probable typo instead of resolving. + - plugin-auth: the object is no longer provisioned, and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry — the installed stable `@better-auth/scim@1.7.1` derives no such model, so the entry bridged nothing. + - plugin-security: the `BETTER_AUTH_MANAGED_OBJECTS` write-deny entry for it is gone with the object (the list is pinned bidirectionally against `managedBy: 'better-auth'` declarations). + + The rc.1-era row was written only by the retired `/scim/generate-token` endpoint; after the stable-1.7.1 migration (PR #12726) nothing could write to it. Per the maintainer's ruling (2026-08-24, 「不需要考虑历史数据」; reaffirmed 2026-08-25 — SCIM has no real customers), **no data migration ships**: existing `sys_scim_provider` tables in deployed databases are left untouched — no backfill, no reaper, no migrate command. SCIM-enabled deployments re-register connections on the stable surface; the IdP token reissue is a migration-day operator action regardless of this change. + + The ADR-0066 D3 capability-gate pin moves from the retired object to its surviving sibling `sys_sso_provider`, so the gate posture stays test-pinned. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`) and the #12726 precedent on the same ruling. + + +- 366f895: feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) + + The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, + `/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, + replaced by seven new models and a three-way connection contract. This lands + the migration atomically: + + - **Seven new platform objects** back the stable models — + `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, + `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, + `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, + registered in the platform-object-names registry, listed in + `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose + `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group + provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — + is closed). + - **SCIM connections stay runtime data.** The stable constructor is satisfied + with an application-owned `authentication.verifyBearerToken` that resolves + the connection from a row at request time — not static boot config, and not + the upstream `managedConnections` catalog (deliberately not adopted). + - **ObjectStack owns SCIM credentials outright** (stable upstream stores no + credential at all): `sys_scim_connection_credential` plus + `scim-connection-service.ts` mint/digest/verify. At rest only an + HMAC-SHA-256 keyed by the deployment auth secret (base64url, + domain-separated) is stored — at parity or better than the rc.1 unsalted + SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live + 401 paths for forged, revoked and expired bearers. + - **The ObjectQL better-auth adapter gains native transactions** + (`engine.transaction`, fail-closed on drivers without `beginTransaction`), + which stable scim requires by assertion for atomic provisioning writes. + - **Scaffold suppression retired**: the `@better-auth/scim>better-call` + `allowedVersions` entry (CLI renderer + blank template) is gone — stable + 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped + to absence pins. The `better-auth>better-sqlite3` and four + `@better-auth/utils` entries stay; their retirement conditions are separate + and unmet. + - The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers + `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace + overrides' silencing would "satisfy" while the family is 1.7.1. Floating is + its own follow-up. + + **Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token + endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — + but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a + documented "do not let the IdP push groups" boundary, and the maintainer ruled + (2026-08-25) that SCIM has no real customers and old data need not carry: the + one binding constraint is that an existing system upgrades smoothly, which it + does — every table the installed library can write exists at this version, and + SCIM-disabled deployments see no behavior change. A major would move the whole + fixed version group for a feature surface with zero consumers. Deployments + that had SCIM enabled must mint new connection credentials (digests are not + portable from rc.1 on any path — IdP token reissue is a migration-day + operator action regardless of semver level). `sys_scim_provider` itself is + NOT removed here; its retirement is tracked separately (#11757). +- 428f9b2: feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) + + Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone + family gets **one** data-plane platform object, declared beside its siblings so + it needs **zero `packages/spec` schema or contract surface** — it is an ordinary + platform object, not a metadata type. (The one spec file touched is the + mechanical name census described below, not protocol surface.) + + The whole schema, per §4: `metadata_type` · `name` · `package_id` · + `organization_id` (nullable, **reserved** — NULL on this entire line; the + per-org dimension is an additive column later, never a redesign) · `active`. + An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); + amendment ruling 2 removed them — there is **no recorded linkage** between a + clone and its base, matching the landed #11513 posture ("an ordinary org-owned + set with no upgrade linkage"). The pin test asserts the column set by EQUALITY + and names both removed columns separately, so re-growing the linkage is loud. + + Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled + as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That + spelling is load-bearing, and the two obvious alternatives are both wrong here: + + - bare `unique: true` on a declared index is the positional spelling of + `'global'` — installation-wide over exactly the listed columns — and is + already warned by lint `unique/unscoped-declared-index` in 17.x; + - a hand-written `['metadata_type', 'name', 'organization_id']` composite is + NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row + by construction, so that index would enforce **nothing at all** (#5030, + measured) and one artifact could carry two contradictory `active` rows. + + `'organization'` is the arm that closes exactly that hole: the driver prepends + `COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which + is what §4's "NULL-collapsed" names. + + The name is also registered in `@objectstack/spec`'s platform-object name census + (`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a + curated set of REAL names, not a `sys_`-prefix pattern, precisely so a + cross-reference check can tell `sys_user` (real) from a fictional + platform-prefixed name; its module contract is explicit that "adding an object + to a platform package means adding its name here", and the owning package's + conformance pin fails otherwise. This is a one-name roster registration, **not** + protocol or schema surface — the ledger remains an ordinary platform object with + no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its + user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` + now answers `true`, so lint stops reading a reference to the ledger as a typo. + + **No behavior change.** This leg ships the declaration only — the enable/disable + actions that write the ledger and the per-runtime consult points that read it + are separate legs, and nothing in the tree reads the object yet. Absence of a + row means the packaged default (**active**), so an empty ledger changes nothing + anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. + The object deliberately declares **no `lifecycle` block** — unlike its telemetry + siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable + configuration, and reaping one would silently re-arm an artifact an + administrator disabled. + +### Patch Changes + +- 3bc2e38: fix(driver-sql): the builtin-column delivery table speaks the spec's field-type vocabulary, not knex's builder names (#12131) + + `BUILTIN_COLUMN_DELIVERY.id.type` recorded `'string'` — the **knex builder name** from + `table.string('id').primary()` — and `undeliveredStorageAttributes` compares that value + with `===` against a declaration's `type`, which is a spec `FieldType`. The two are + different vocabularies, and `'string'` is not a member of the one being compared: it is + absent from `FieldType`'s 49 options, `Field.string` is absent from the builder's keys, + and `FieldSchema` refuses `type: 'string'` outright. So **no declaration could ever + match it**, and the #12015 diagnostic reported every correct declaration on the + platform's own key as a disagreement. + + Measured on a stock boot of `@objectstack/platform-objects`: **45 warnings, one per + system object**, each saying `type: 'text' (the column is 'string')` about a + declaration that was right all along. `varchar` canonicalizes to the field type `text` + (`canonicalizeSqlType('varchar(255)') === 'text'`, `suggestFieldTypeForSqlType('varchar(255)') === 'text'`, + `isCompatible('varchar(255)', 'text') === true` — all pinned in `type-compat.test.ts`), + so `id: Field.text(...)` asks for exactly what the platform's column delivers. The + delivery table now records `text`, and the 45 lines go silent because they were false, + not because they were suppressed. + + `sys_migration.id`'s `maxLength: 128` was the one **honest** disagreement in that corpus + — the column is varchar(255) — and it is removed rather than widened to 255. It bound + nothing in any seam: the DDL discards a declared width on a builtin column name, and + `validateRecord` skips `id` by name on both the insert and the update path (it is also + `readonly`). Declaring a width that nothing enforces is the shape enforce-or-remove + exists to prevent, and the 44 sibling system objects declare none. + + The classification pin now holds **every** entry in the delivery table to + `FieldType.options`, so a builder name written there fails by name instead of surfacing + as a corpus of false warnings. The fixtures in both #12015 pin files were written + against the delivery table rather than against the source — `sys_presence.id` was spelled + `type: 'string'` in the "silent" cases, which is why they passed while the same + declaration as actually written warned. They now use the shapes as declared, and the + firing cases declare a type that genuinely disagrees. + + **Grade: `patch` for both, and deliberately.** No door moves and no DDL changes: the + platform still owns `id` / `created_at` / `updated_at`, the emitted column is + byte-identical, every object that booted before still boots, and `BUILTIN_COLUMN_DELIVERY` + is internal to the package (it is not re-exported from the package entry). The + `platform-objects` half removes one metadata key that was measured inert in every seam + that could read it. What changes is what the driver **says**. +- 5894d30: Surface the email-invite entry on the organization record page's default + Members tab, and stop it rendering as a twin of "Add Member" + + The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on + tab-0 **Members**, whose related-list toolbar carried exactly one action — + `add_member`, which attaches an **already-registered** user by id. The + email-invite entry, `invite_user`, was declared only on `sys_invitation` and + `sys_user`, so it appeared only on tab-1 Invitations. An admin looking to + "invite a teammate by email" landed on Members, found no invite affordance and + concluded the product had none. The delivery half worked the whole time + (`sendInvitationEmail`, template `auth.invitation`) — only the door was in + another room. + + `sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of + `add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same + email + role inputs, and the same `requiresFeature: 'organization'` capability + gate as the other two mirrors. Declaration order is render order in the + related-list toolbar bridge, so the invite button sits left of the attach one. + + **The `email` param names `objectOverride: 'sys_invitation'`, and must.** + `sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` + declaration would leave the param unresolvable — the renderer answers that with + a `type: 'text'` fallback labelled by the raw field name, which still submits + and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no + override: `sys_member` declares it, from the same + `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now + holds this over **all three** mirrors, so the next copy of any action cannot + reintroduce the shape. + + `add_member` keeps its behaviour and its label and is differentiated only in + chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing + record" icon `sys_account`'s `link_social` already uses) — so the two buttons + no longer render as identical primary `user-plus` twins. Both halves are + honoured by the renderer: it draws `primary` filled and every other variant + outlined. + + The `@objectstack/spec` half is one line of registry bookkeeping: + `PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as + it already books the other twelve. No schema, export or authorable key changes. +- 064d484: Move both authored `record:alert` gates off `properties.visible` onto the + component-node `visibleWhen`, `has()`-guarded and served as a CEL envelope + (#9167) — the `sys_user` detail page's "Email not verified" banner, and the + showcase Task Detail page's "Awaiting review" banner. + + `record:alert` is the one record component that declares a props-level + `visible` predicate, but `PageComponentSchema.properties` is an opaque record: + the bag is served verbatim, so a bare string in `visible` never reaches + `ExpressionInputSchema` and is evaluated by the console's **legacy JS** + evaluator, which has no `has()`. The node-level `visibleWhen` declared at + `page.zod.ts:189` *is* an `ExpressionInputSchema`, so a page that goes through + the spec's transform serves `{ dialect: 'cel', source }` and runs on CEL — the + same engine, and the same `has()` semantics, every other predicate face was + migrated to. + + Three properties of that move were measured in a real console at the pinned + objectui SHA rather than reasoned about, and all three are load-bearing: + + - The `visible` key is **deleted**, not left beside the new gate. A node + `visibleWhen` and `properties.visible` compose as **AND**, so keeping both + would leave the legacy predicate load-bearing and make the migration + cosmetic. + - The `has()` guards are **mandatory**. On the CEL face an absent key is a + *fault*, and that face is fail-soft: measured, an unguarded gate with its key + stripped from the read left the banner VISIBLE, where the guarded gate hid + it. + - On `sys_user` the predicate is authored through `P` so it reaches the wire as + a CEL **envelope**. `SysUserDetailPage` is a raw `Page` object literal, so — + unlike a page built with `definePage()` — nothing normalizes it, and the + renderer keeps bare strings on the legacy path by design. Measured: the bare + form left "Email not verified" showing on *every* profile, including other + people's; the envelope restores every polarity. + + Behaviour for real users is unchanged in every polarity measured — a `todo` + task hides the banner and an `in_review` task shows it; a verified user hides + "Email not verified", an unverified user viewing their own profile shows it, + and another user's profile shows nothing. What changes is that a genuine fault + is now **loud** (CEL names the missing key) instead of silently answering + `false`, and that both predicates sit on the declared slot the platform teaches + everywhere else. +- 033a34c: **Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). + + The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). + + **What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. + + **Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. + + `@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. +- e0abc38: fix(platform-objects): add the Setup nav entry for the packaged-automation page (#12457, ADR-0126 §7.4) + + The packaged-automation page (on/off per packaged flow/action, clone for + flows) shipped complete in the console (objectui app-shell, registered under + the component ref `automation:packaged`), but no framework + `NavigationContribution` ever named that ref — so on every stock boot the + page was reachable only by a hand-typed URL + (`/apps/setup/component/automation/packaged`), and Setup's sidebar carried no + entry. Epic #12150's L5/L6-UI cards closed with the objectui half pinned and + the framework half missing; `content/docs/build-without-code.mdx` promises + the page publicly. + + `SETUP_NAV_CONTRIBUTIONS` now contributes `nav_packaged_automation` + (`type: 'component'`, `componentRef: 'automation:packaged'`) in `group_apps` + beside Packages — package administration is Operate (ADR-0084), and ADR-0126 + §7.4 rules "Studio keeps the editing; Setup gets the operational state". The + entry deliberately carries no `requiresService: 'automation'` (the action + switches ride the `sys_metadata_activation` ledger this package registers and + work on compositions with no automation service, #12419) and no + `requiredPermissions` (matches `nav_packages`: the app's `setup.access` gates + entry, the activation write doors enforce `manage_metadata` / the §5 operator + gate server-side). Labels land in all four locales with recorded source + hashes; `setup-packaged-automation-nav.test.ts` pins the framework half of + the cross-repo contract the objectui nav test pins from its side. +- 598b7ec: fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) + + `os i18n extract` merges gaps only, so a revised source string leaves the previous + revision standing in every translated locale — in sync by key, green under + `check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves + `check:i18n-stale-fill` froze in its baseline are re-translated here from the + **current** `en` source, and the baseline is ratcheted to empty in the same change. + + User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: + + - `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a + `"certified"` governance flag that was removed from the declaration in 16.0. + - `sys_webhook.fields.method.help` — all three locales served the pre-revision method + enumeration after the source became a prose description. + - `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now + Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose + uses and which carries no plural inflection. + - `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and + `zh-CN` a translation of the same superseded source; all three now carry the + PARKED / terminal-row clause the source documents. + - `sys_notification_subscription.fields.principal.help` — the selector list was missing + the `owner_of:object:id` and bare-email forms in all three locales. + + No schema, export or runtime behaviour changes: translated-locale leaf values only, + plus the shrink-only ratchet baseline. +- 811a3c2: fix(platform-objects): `sys_secret` field help stops asserting the settings-only reading (#12550) + + `sys_secret` has **three** privileged producers (#4270) — the object's own + `managedBy` note has said so for a while — but its most load-bearing field + descriptions still described a settings-only table. Those strings are not + internal comments: they are the field help an operator reads in the + `sys_secret` grid, and they compile into the shipped translation bundles. + + Measured on `origin/main@f93df4db`, by producer symbol rather than by line: + + | producer | `namespace` | `key` | the reference lives at | + |---|---|---|---| + | `SettingsService` (`settings-service.ts`, `secretStore.insert`) | settings namespace | specifier key | `sys_setting.value_enc` | + | engine `encryptSecretFields` (`objectql/src/engine.ts`) | **object name** | **field name** | a `secret:` ref on the business row itself | + | datasource credential binder (`datasource-secret-binder.ts`) | **caller-supplied**, default `datasource` | datasource name | the artefact's `sys_secret:` credentialsRef | + + So `'Settings namespace this secret belongs to.'` / `'Specifier key within the + namespace.'` / `'Opaque handle referenced by sys_setting.value_enc.'` were each + true of one producer out of three, and the pair they describe was presented as + if it identified an owner. That is exactly the `(namespace, key)` attribution + reading #8103's re-measurement rejected — the reason + `sys-secret-orphan-report.ts` reports a row it cannot attribute as + `'unattributable'` rather than `'orphaned'`. Field help asserting the rejected + reading is the safety-relevant direction of this drift. + + Corrected here: the object description, and the `namespace` / `key` / `id` + field descriptions, now name the producer-scoped reality and point at + `managedBy`. The `en` bundle was regenerated with the repo's own + `pnpm i18n:extract`; the three translated locales carried translations of the + superseded English, so their four affected leaves were re-translated by hand — + the action `.objects.generated.ts`'s own header prescribes when a + source string changes — and the bundles plus their `--source-hashes` + companions then come from one extract run. + + Text only. No field is added, removed, renamed or re-typed; no validation, + persistence or access rule moves; every `sys_secret` payload that parsed before + parses identically. ⛔ A producer/owner column stays out of scope — that is a + persist-path change and belongs to whoever takes that decision. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 310676c89f..57442849b7 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.2.0", + "version": "17.3.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 6d8ddf03ba..a17aa81417 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/embedder-openai +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 231aeb3396..f60c531c33 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.2.0", + "version": "17.3.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 dbe5386128..ec8d368d48 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/knowledge-memory +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-knowledge@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 9dc25205a0..f25ae31361 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.2.0", + "version": "17.3.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 f290bbc623..f4a33ea162 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/knowledge-ragflow +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-knowledge@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 2e2df76042..9298364162 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.2.0", + "version": "17.3.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 e64d625288..ebc6d98bb0 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,487 @@ # @objectstack/plugin-approvals +## 17.3.0 + +### Minor Changes + +- 277948f: feat(spec,plugin-approvals): `escalation.enabled` defaults to `true` and the SLA sweep finally reads it (#12278) + + **BREAKING** semantic default flip on a published authorable key, shipped as + `minor` under the repo's launch-window convention for breaking changes. + Maintainer ruling 2026-08-27 (Option C), explicitly reversing the 2026-08-26 + "spec stays as declared" ruling with fresh analysis. + + `ApprovalEscalationSchema.enabled` declared `default(false)` while the + plugin-approvals escalation sweep never read the key: any escalation block + with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` + that silently approved requests their author had declared off the clock — + the ADR-0049 declared-but-unenforced shape, failing open. Worse, the + approval-node executor parses node config through the schema before + snapshotting it onto the request row, so the old default **materialized** + `enabled: false` into storage for every author who omitted the key, making + "authored off" and "defaulted off" byte-identical at the sweep site. + + One change, both halves: + + - **spec**: `enabled` now defaults to `true` (stays `z.boolean()`; no + tri-state). The feature-level switch is whether an `escalation` block + exists at all; within a block carrying `timeoutHours`, escalation is on + unless explicitly turned off — which is what the runtime, its eleven + behaviour tests, and every teaching surface have always meant. Declared in + `DEFAULT_CHANGES_BY_MAJOR` (17) and registered as the + `approval-escalation-enabled-default-flip` semantic migration entry. + - **runtime**: `runEscalations` skips a request whose snapshot carries an + explicit `escalation.enabled === false` — the declared switch is enforced. + Request snapshots created **before** the flip cutoff + (`ESCALATION_ENABLED_FLIP_CUTOFF_MS`, 2026-08-28T00:00:00Z) ride a + read-side legacy window and keep escalating exactly as they do today: their + stored `false` is overwhelmingly the old schema default materialized onto an + author who never wrote the key, every such stored row is escalating today, + and the window retires itself as pending requests drain — zero tenant rows + rewritten. + + Deployed metadata that omits `enabled` does not change behaviour (it + escalated before, it escalates after). What changes is that writing + `enabled: false` finally binds for newly opened requests — the declared + intent being honoured. To keep an SLA off, write `enabled: false`; to + escalate on timeout, an `escalation` block with `timeoutHours` is enough. + + +- 3690922: fix(plugin-approvals): the non-submitter recall refusal renders through the + Operation Message Catalog instead of a hardcoded English sentence (#11993, the + services-side half of the shape-A ruling) + + A user who opened a record someone else had submitted for approval, clicked + Recall and was correctly refused read the reason in English regardless of their + own locale. `@objectstack/rest`'s `handleApprovalError` ships this service's + thrown reason as the 403 body's human-readable `error`, and Console splices it + under its own localized label — so an operator in a fully Chinese deployment + read a Chinese prefix glued onto an English sentence they could not act on + (`撤回审批失败: `). + + The refusal now renders through the shared Operation Message Catalog in + `@objectstack/spec/system` under the key `approval_recall_not_submitter` that + #12493 landed for it — the same mechanism `plugin-security`'s denial gates + already use, with the same resolution ladder (deployment override → the + caller's locale → `en` → the key) and the same guarantee that a misbehaving + i18n service cannot turn a 403 into a 500. All four platform locales (`en`, + `zh-CN`, `ja-JP`, `es-ES`) ship copy that names who *can* recall, rather than + dead-ending the reader. + + `ApprovalServiceOptions` gains an optional `messageTranslator` — a lazily + resolved, `II18nService.t`-compatible lookup, wired by `ApprovalsServicePlugin` + the same way `tenancyPosture` and the field-visibility source are, because the + i18n service is contributed by another plugin and may start later. It is what + makes the override address the catalog documents, + `errors.approval_recall_not_submitter`, actually take effect for this emitter; + a stack without an i18n service still renders the built-in catalog in the + caller's locale. + + **Not changed: who may recall an approval.** The gate is byte-identical — the + submitter, or a privileged admin releasing a stuck record (#3424). Only the + sentence the refusal carries is different, and the `FORBIDDEN:` code prefix + that the REST layer maps to 403 is untouched. + + The button-visibility half of #11993 — a non-submitter seeing a live recall + button at all — is not addressed here; see the issue for the measurement. +- 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + + The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + + The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. + +### Patch Changes + +- 2a05851: fix(plugin-approvals): the `approval_recall` action shows for the #3424 admin override (#12716) + + `ApprovalService.recall` has admitted two callers since #3424 — the submitter, + and a platform/tenant admin releasing a stuck request — and `isOverrideActor`'s + own doc block names recall as one of the four override levers in so many words. + The declared action that reaches that endpoint did not agree: `approval_recall`'s + `visible` predicate was submitter-only, while its three siblings + (`approval_approve` / `approval_reject` / `approval_reassign`) each OR in + `record.viewer.can_override`. + + So recall was the one lever the override covers whose button never appeared. An + admin rescuing an approval routed to an unstaffed position could approve or + reject their way out — writing a decision nobody made — or reassign it, but + could not simply withdraw it. This is declared-vs-enforced drift in the less + usual direction: a capability the server grants that no UI entry exposed. + + `approval_recall`'s `visible` now ORs in `record.viewer.can_override`, spelled + byte-identically to the three siblings. + + Not a permission change: the service's authorisation set is untouched, and + `can_override` was already computed server-side for every viewer. + + **Pending-only, and enforced rather than asserted.** The new arm carries no + status test of its own — neither do the siblings — because the flag is already + status-scoped where it is computed: `attachViewers` sets + `can_override: row.status === 'pending' && isOverrideActor(...)`, ANDed, so the + flag can never be true off `pending` and the arm is pending-only in effect + however CEL groups the expression. The submitter's own `returned` (revise + window) arm is unchanged. Pinned in both directions, with the flag's own scoping + pinned against the real service on a genuinely `returned` row. +- 9bc403e: Correct what `sys_approval_request.payload_json` is documented to be FOR — it is audit evidence served redacted per reader, not a notification source + + The object's module docstring — which ships to consumers in the package's type + declarations — justified the snapshot column with: *"used by notifications so + they can render before the record is locked or changed."* That consumer does + not exist. Measured against every `this.notify(...)` call site in + `approval-service.ts`, all **12** of them, each passes a payload of + `{ title, message, actionUrl }` (two also carry `actions`), built from + `object_name` / `record_id` and the caller's own comment. **None** reads + `payload_json` or the parsed `payload`. + + This is more than tidiness: that sentence was the only documented + justification for the column holding a *full* row, and it was cited as such + during the #10749 consumer inventory before anyone checked it. The docstring + now states the real reason — the snapshot is retained as **audit evidence of + what was actually submitted**, so the column stays whole at rest, and is served + **redacted per reader** by the subject object's field-level read controls via + `getReadableFields`, on the approvals-inbox door and the generic data door + alike (#11039). + + The field's own `description` is deliberately unchanged: `Record snapshot at + submission time` is accurate, and it — unlike the JSDoc — is the string + extracted into the four generated i18n bundles, so no translation leaf moves + and no locale is left holding an English seed. + + Also carried in the same pass, the residual documentation the #10749 closure + assigned to the next docs touch in this lane: `payload-redaction.ts` recorded + `hidden`-vs-serialization as an **open** `packages/spec` question, and it has + since been ruled (maintainer, 2026-08-24, applying the 2026-08-12 lineage). + That paragraph now states the ruling — **`hidden: true` stays UI-only; + `internal: true` is the serialization primitive** — so an author who needs a + field kept out of read results is pointed at `internal: true` (#7728, + ADR-0049) rather than at `hidden`, which never governed serialization. + + Documentation only: no runtime behaviour, no schema field, and no public type + signature changes. +- 5a916c4: **Ops:** a one-off, idempotent backfill for the platform rows the pre-#10101 writers stranded with no organization — dry run first (#11308). + + #10101 fixed the WRITERS: a `sys_approval_request` and a `sys_automation_run` are now stamped from the SUBJECT record's organization, with the acting context as the ruled fallback. It wrote nothing to existing rows, so the population produced before it persists — a **pending** org-less approval request LOCKS the record it is about while being invisible in every organization-scoped inbox, its own owner's included, and automation-run history stays unattributed. This is the repair for those rows, on the maintainer's 2026-08-23 ruling (direction 3). + + `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` sweeps `sys_approval_request` (with its `sys_approval_action` / `sys_approval_approver` children, which move with their request) and `sys_automation_run`. It scans only rows whose organization column is unset, re-reads each row's subject at repair time — live record first, the write-time snapshot (`payload_json` / `context_json`'s `record`) second for a subject that has since been deleted — and stamps the platform row with the subject's own organization. + + **Dry run first, and by default.** `planPlatformRowOrganizationBackfill(engine)` reads only and returns a per-object report naming every row it would touch; `runPlatformRowOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install. + + **Rows whose subject is equally org-less are counted and named, never written.** The acting-context fallback the writers apply is not available to a repair — the acting context is gone — and inventing one stays vetoed. Those ids are reported so the population is checkable and stays visible. + + **`sys_api_key`'s divergence is preserved, not flattened.** Both the column read on a subject and the column written on a platform row are resolved from the registered schema through the shared `createRecordOrganizationResolver` (`@objectstack/metadata-core`), so a platform row ABOUT an API key is repaired from that object's stamp-only `active_organization_id` (limb 0, #8778) and the credential table itself is never written to. + + **Idempotent, and asserted rather than claimed.** Every scan is `WHERE IS NULL` and every write fills that column, so a repaired row cannot match again; the test suite runs the sweep twice and pins the second run at zero writes. + + Publishes no runtime code: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first. +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index a1e68fe728..398032c271 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.2.0", + "version": "17.3.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 f774013205..b37b961d90 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,457 @@ # @objectstack/plugin-audit +## 17.3.0 + +### Minor Changes + +- 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + + The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + + The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. +- 8d237b4: Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. + + The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. + +### Patch Changes + +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- f64668d: fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync (#12059) + + Four text columns that a declared index keys on carried no `maxLength`, so + `driver-sql` emitted them `TEXT`. MySQL refuses a TEXT/BLOB column in a key + without a key length (`ER_BLOB_KEY_WITHOUT_LENGTH`): `CREATE TABLE` succeeds, + `ALTER TABLE … ADD INDEX` fails, and the object lands registered-but-broken + with its declared index silently absent. + + | Object | Column | Bound | Producer the bound is derived from | + |---|---|---|---| + | `sys_activity` | `record_id` | 255 | the physical `id` column — `driver-sql` creates every primary key as `table.string('id').primary()`, knex's `varchar(255)` | + | `sys_audit_log` | `record_id` | 255 | same | + | `sys_audience_binding_suggestion` | `package_id` | 255 | `sys_permission_set.package_id` (255), which the same boot pass writes the same value into | + | `sys_audience_binding_suggestion` | `permission_set_name` | 100 | `sys_permission_set.name` (100), the column this value resolves against at confirm time | + + Each bound is derived from a **named producer** and stated in the declaration + so it is vetoable in review (#11374 route A; PR #12058 is the worked + precedent). None of them narrows anything storable: + + - a record id cannot exceed the `varchar(255)` column the id itself lives in, + and the `referenceVia` seed path refuses an unresolvable pointer rather than + storing a natural key verbatim; + - a permission set name longer than 100 is already refused at the write seam + today — measured on a real engine, `ValidationError: API Name must be ≤ 100 + characters (got 101)` — so no set with such a name can exist, and a + suggestion naming one could never be confirmed. + + Measured at the driver level, shipped declaration vs. the same declaration with + the bounds stripped: `record_id`, `package_id` and `permission_set_name` move + `TEXT` → `varchar(255)` / `varchar(100)`, while `id` reads `varchar(255)` in + both — the transitivity premise, read off a real table rather than assumed. + + Existing deployments are not rewritten: a physical `TEXT` column is deliberately + not diffed against `maxLength` (#11431), so no `ALTER` is planned and no value + at rest is truncated. The repair takes effect where the decision is makeable at + all — at `CREATE TABLE` — because no dialect turns a TEXT column into a keyable + one afterwards. + + Each plugin also gains a keyed-text-bounds pin driven through its **own + registration path** (`init()` → the manifest `register({ objects })` call), + rather than a hand-written object list: the platform-objects pin enumerates only + that package's exports, which is exactly why these four columns escaped route + A's sweep after ADR-0029 K2 moved the objects out. +- 2d8dd8d: Publish the built-in `sys_activity.type` vocabulary from `@objectstack/spec` (#11807). + + `SYS_ACTIVITY_BUILTIN_TYPES` (and the derived `SysActivityBuiltinType` union) is now exported from `@objectstack/spec/data` (`feed.zod.ts`, alongside `FeedItemType`), and the `sys_activity` object declaration in `@objectstack/plugin-audit` derives its `type` options from it — one source instead of a hand-copied list per consumer. + + The constant is the platform's **built-in set, not the column's value domain**: `sys_activity.type` stays an open, author-extensible vocabulary (#11507 ruling — an app may contribute values via `activityMilestones[].type`, ADR-0052 §5b.2, or its own inserts, and undeclared values are stored verbatim). It is deliberately a plain `as const` tuple rather than a `z.enum`, so it cannot be used as a validator; consumers must render unknown values, never drop them. UI packages that hand-copied the list (objectui's feed-kind census, which drifted the day #11522 added `scheduled`) can now read this export instead. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- 91b1342: Declare `sys_activity.type: 'scheduled'` and record its writer in the type census + + A shipped app action writes the value today. `objectstack-ai/hotcrm` + `src/actions/global.actions.ts` builds a `schedule_meeting` action for + `crm_lead`, `crm_contact`, `crm_account`, `crm_opportunity` and `crm_case` + whose body runs `ctx.api.object('sys_activity').insert({ type: EVENT_STATUS + === 'held' ? 'completed' : 'scheduled', … })`, and `SCHEDULE_MEETING_SPEC` + declares `eventStatus: 'planned'`, so that action always takes the `scheduled` + branch. Read at hotcrm `5eee1bd` on 2026-08-24. + + **Nothing accepts or rejects differently.** Every `sys_activity` field is + `readonly: true`, and `validateRecord` skips readonly fields on both the insert + and the update branch (`objectql/src/validation/record-validator.ts`), so the + `invalid_option` check that enforces a select field's declared options never + runs for one — measured here, not assumed: with the value added to the enum and + nothing else changed, exactly one of the package's 313 assertions moved, the + writer census below, which exists to force that row to be written. Every + behavioural case stayed green, including the one that inserts an undeclared + value into this very column and measures that it lands. Before and after, the + row is stored verbatim. What changes is that the declaration names the value + the platform stores, so the timeline filter offers it and the four generated + locale bundles carry a label for it (`已安排` / `予定` / `Programado`). + + The census (`sys-activity-type-vocabulary.test.ts`) gains the writer row, which + is what forces this to be measured rather than asserted: adding a value to the + enum without inventorying its writer fails that pin. Two further facts are + recorded there in passing — `completed` has a second writer at the same app + (`src/actions/contact.actions.ts`), and the census's in-repo sweep cannot see + either of them, because an app's server-side action reaches this column directly + through `ctx.api`. +- 88b9d74: Say out loud that `sys_activity.type` is an open, author-extensible vocabulary + — the declared options are the platform's **built-in** set, not a closed enum + + An author reading the declaration learned "writing another value will be + rejected". That was false in three independent ways, and the declaration was + the only place that did not say so. + + 1. Every field on `sys_activity` is `readonly: true`, and `validateRecord` + skips readonly fields on both write branches, so the `invalid_option` check + a `select` normally implies **never runs** on this column. + 2. ADR-0052 §5b.2 `activityMilestones[].type` is `z.string().optional()` in + the spec and is forwarded verbatim by the audit writer + (`if (milestone.type) activityType = milestone.type`) — a shipped, + documented, author-facing channel straight into the column. + 3. An app's own server-side action writes the column directly + (`ctx.api.object('sys_activity').insert({ type: … })`); no grep of this + repository can see those sites. + + Maintainer ruling, 2026-08-24 (#11507, direction 4 of four): the column **is** + an open vocabulary, ADR-0052 §5b.2 **stays** a sanctioned write path, and + every closed map over this vocabulary is now the bug. The status quo was the + one option more dangerous than either end state — most of all to an AI writing + metadata, which reads the declaration and believes it. + + So the declaration now carries the semantics, in the field's own + `description` — the slot the spec declares for exactly this and, unlike a + source comment, one the contract carries wherever the metadata goes (the + metadata API, the i18n bundles, whatever an author or an AI reads about this + field). No new schema concept was invented: `FieldSchema` has no + open/closed-vocabulary key, and the pin measures that rather than asserting + it, so the day `packages/spec` grows one this declaration is told to move. + + Nothing about enforcement changed — that was direction 3 and it was **not** + ruled. `validateRecord` is untouched, the built-in set is unchanged (twelve + values), and both existing vocabulary tests keep every assertion they had. + What changed in them is what a red MEANS: the two cases that used to be filed + as "a defect, characterized — delete these when enforcement lands" now measure + a ruled contract, and say that rejecting an author-contributed value is a + contract change to re-open #11507 over, not a fix to adapt them to. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index c186a0fe70..706b25762f 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.2.0", + "version": "17.3.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 a05281c729..bb0819e00c 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,787 @@ # Changelog +## 17.3.0 + +### Minor Changes + +- 4f24e9d: feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + + **BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + + - New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. + - **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. + - `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. + - Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. + - `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. + - The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. + - `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + +- 8e31083: feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) + + The audience posture shipped by #11739 (`invite_only | email_domain | open`, + default `invite_only`) was switchable only from stack config at boot; a + self-host admin had no console channel. The `auth` settings namespace now + carries an `audience` group — three new authorable keys, which is what a host + sees and why this is `minor`: + + - `audience_posture` — a select over the closed vocabulary (the option table + is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override + door); + - `audience_allowed_email_domains` — newline- or comma-separated bare domains + (exact, case-insensitive matching; subdomains need their own entries); + - `audience_self_registration_permission_set` — the `sys_permission_set` name + each self-registrant receives. + + `bindAuthSettings` maps the three keys — one atomic declaration — to one + `AuthManager.applyConfigPatch({ audience })`, which replaces the whole + audience object and validates the MERGED result. Every #11739 invariant holds + through the new channel: a self-registration posture with verification + explicitly off, an empty domain list under `email_domain`, and a missing or + `admin_full_access` permission set are all refused loudly (the standing + config keeps ruling — fail closed), and off-vocabulary postures are refused, + never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT + settings values apply: the manifest defaults never mask a deployment's + boot-config declaration. Switching back to `invite_only` always applies — + leftover text in the posture-hidden sibling fields cannot make closing the + wall refusable. +- 33184fd: `PLATFORM_ADMIN` can now be anchored on deployment CONFIGURATION instead of a stored grant row: an account whose `sys_user.email` is on `OS_PLATFORM_OWNER_EMAIL` **and** whose `email_verified` reads verified resolves `PLATFORM_ADMIN` with the declared `admin_full_access` capability set, derived live on each authorization resolution (#11663 leg L2, design accepted 2026-08-25 as bundle 1A/2B/3A/4A/5A/6A/7A). + + **Additive — nothing is revoked.** The legacy unscoped `admin_full_access` grant still confers exactly as it did; a holder whose standing rests on the row alone now gets a once-per-process pointer at the configuration line that re-anchors them. A deployment that has declared no administrators resolves byte-identically to before: the config list is empty, the derivation answers "not an admin" before it reads any row, and the pinned batch-equivalence query multiset is unchanged. + + **The variable takes a list.** `OS_PLATFORM_OWNER_EMAIL` accepts one address or a comma-separated list of them — one normalization (`trim().toLowerCase()`), duplicates collapsed, blank entries dropped. ⛔ Any entry that is not an address **fails the whole variable closed** with a loud refusal naming it, rather than being skipped: silently dropping a typo would leave a narrower administrator set than the operator declared, with nothing anywhere to notice. Unset, blank or refused all mean **zero** config-derived administrators. + + **Verified-email match only.** An unverified account holding a configured address confers nothing, and an ABSENT `email_verified` column reads unverified. The match reads the caller's own **stored** `sys_user` row, never the caller-supplied session email. + + New exports from `@objectstack/core`: `resolvePlatformAdminEmails`, `parsePlatformAdminEmails`, `matchesConfiguredPlatformAdmin`, `normalizePlatformAdminEmail`, `PLATFORM_ADMIN_EMAIL_SEPARATOR`, `ADMIN_STANDING_NON_TABLE_INPUTS` and the test hooks beside them. `@objectstack/core` now depends on `@objectstack/types` (measured acyclic: `types` depends only on `spec`). + + `@objectstack/plugin-auth`'s break-glass guard follows the derivation, as it must: `ADMIN_STANDING_SURFACE.sys_user` is reclassified `derives`, the last-administrator enumeration counts config-derived administrators through the resolver's own predicate, and a fifth write shape is judged — a change of address or an `email_verified` reset that would leave the environment with no administrator is refused, naming the configuration as the remedy. An ordinary profile write still costs the guard no reads. +- 911da5f: **BREAKING (public export removed):** `AUTH_SCIM_PROVIDER_SCHEMA` no longer exists. It was a `scimProvider` column mapping exported from `@objectstack/plugin-auth` (via `export * from './auth-schema-config.js'`) that nothing ever read — one repo-wide hit, its own declaration — and that nothing ever could: `@better-auth/scim` hardcodes its model and exposes no `schema` option, still true of the installed `@better-auth/scim@1.7.0-rc.1`, whose `SCIMOptions` declares no `schema`, `modelName` or `fields` member at all. Its sibling constants in the same file are genuinely passed to their plugins; this one had nowhere to go, by construction and by its own doc comment. + + Removed under ADR-0049 enforce-or-remove, because it was a **second source of truth** for the same four column names. The load-bearing one is the adapter layer — `AUTH_MODEL_TO_PROTOCOL` plus the mechanical camelCase-to-snake_case field resolution in `objectql-adapter.ts`, over the `sys_scim_provider` platform object that declares the columns — and it is pinned by the dedicated sso/scim block in `better-auth-schema-parity.test.ts`. A dead copy is worse than none: nothing fails when it drifts from the live names, and the next reader cannot tell which of the two is authoritative. A comment in its place records why no such mapping exists and what owns the names instead, so it is not re-added. + + Behaviour is unchanged. No SCIM column name, platform object, adapter mapping or wire shape moves. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + +- 4d25d22: **BREAKING (platform object removed):** the `sys_scim_provider` platform object is retired (#11757, ruled on #11693 — leg 1a of the #11632 SCIM epic). + + FROM → TO, per surface: + + - `SysScimProvider` (export of `@objectstack/platform-objects` / `.../identity`) → removed, no replacement export. Fix: delete the import. Stable SCIM state lives on the seven `sys_scim_*` stable-model objects (#3653), and connection credentials on `sys_scim_connection_credential`. + - `sys_scim_provider` in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) → removed. `isPlatformProvidedObjectName('sys_scim_provider')` is now `false`, so a stack referencing the name is flagged as a probable typo instead of resolving. + - plugin-auth: the object is no longer provisioned, and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry — the installed stable `@better-auth/scim@1.7.1` derives no such model, so the entry bridged nothing. + - plugin-security: the `BETTER_AUTH_MANAGED_OBJECTS` write-deny entry for it is gone with the object (the list is pinned bidirectionally against `managedBy: 'better-auth'` declarations). + + The rc.1-era row was written only by the retired `/scim/generate-token` endpoint; after the stable-1.7.1 migration (PR #12726) nothing could write to it. Per the maintainer's ruling (2026-08-24, 「不需要考虑历史数据」; reaffirmed 2026-08-25 — SCIM has no real customers), **no data migration ships**: existing `sys_scim_provider` tables in deployed databases are left untouched — no backfill, no reaper, no migrate command. SCIM-enabled deployments re-register connections on the stable surface; the IdP token reissue is a migration-day operator action regardless of this change. + + The ADR-0066 D3 capability-gate pin moves from the retired object to its surviving sibling `sys_sso_provider`, so the gate posture stays test-pinned. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`) and the #12726 precedent on the same ruling. + + +- 366f895: feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) + + The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, + `/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, + replaced by seven new models and a three-way connection contract. This lands + the migration atomically: + + - **Seven new platform objects** back the stable models — + `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, + `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, + `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, + registered in the platform-object-names registry, listed in + `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose + `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group + provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — + is closed). + - **SCIM connections stay runtime data.** The stable constructor is satisfied + with an application-owned `authentication.verifyBearerToken` that resolves + the connection from a row at request time — not static boot config, and not + the upstream `managedConnections` catalog (deliberately not adopted). + - **ObjectStack owns SCIM credentials outright** (stable upstream stores no + credential at all): `sys_scim_connection_credential` plus + `scim-connection-service.ts` mint/digest/verify. At rest only an + HMAC-SHA-256 keyed by the deployment auth secret (base64url, + domain-separated) is stored — at parity or better than the rc.1 unsalted + SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live + 401 paths for forged, revoked and expired bearers. + - **The ObjectQL better-auth adapter gains native transactions** + (`engine.transaction`, fail-closed on drivers without `beginTransaction`), + which stable scim requires by assertion for atomic provisioning writes. + - **Scaffold suppression retired**: the `@better-auth/scim>better-call` + `allowedVersions` entry (CLI renderer + blank template) is gone — stable + 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped + to absence pins. The `better-auth>better-sqlite3` and four + `@better-auth/utils` entries stay; their retirement conditions are separate + and unmet. + - The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers + `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace + overrides' silencing would "satisfy" while the family is 1.7.1. Floating is + its own follow-up. + + **Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token + endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — + but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a + documented "do not let the IdP push groups" boundary, and the maintainer ruled + (2026-08-25) that SCIM has no real customers and old data need not carry: the + one binding constraint is that an existing system upgrades smoothly, which it + does — every table the installed library can write exists at this version, and + SCIM-disabled deployments see no behavior change. A major would move the whole + fixed version group for a feature surface with zero consumers. Deployments + that had SCIM enabled must mint new connection credentials (digests are not + portable from rc.1 on any path — IdP token reissue is a migration-day + operator action regardless of semver level). `sys_scim_provider` itself is + NOT removed here; its retirement is tracked separately (#11757). +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + +- 4d5b4f8: feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (#12751) + + On a **walled** deployment (`OS_TENANCY_POSTURE` in the wall-enforcing + family), the account whose email equals the declared platform owner + (`OS_PLATFORM_OWNER_EMAIL`) is stamped `emailVerified` **at creation** when + it comes into existence through an **operator provisioning path** — extending + the #11343 dev-boot seeded-admin precedent to production walled boots + (maintainer ruling 2026-08-28, cloud#1677: 「运营方创建即视为已验证」; the + trust anchor is the operator's env-var declaration plus the + operator-executed creation, not a mailbox round-trip; SMTP stays required + only for inviting others). + + **Which creation paths qualify** (the [#11739] audience taxonomy, not a + second classification): + + - the **bootstrap carve-out** — the very first account on a fresh install + (zero human users), the one self-serve creation a walled boot admits; + - **admin create-user / bulk import** (`method: 'admin'`) — an act only an + authenticated admin session can perform; + - **SCIM** (`method: 'scim'`) — provisioning executed by the + operator-registered directory. + + **Never**: non-bootstrap self-registration (including an + invitation-admitted registration typing the owner address), provider-class + JIT (the IdP asserts its own `emailVerified` at insert), any non-owner + address, any unwalled posture, and a later email **update** to the owner + address (the stamp is staged at the admission gate and consumed once by the + `user.create` before-hook — a seam an update cannot traverse). Dev-boot + behaviour (#11343) is unchanged. + + The `WALLED_OWNER_NO_VERIFICATION_PATH` boot warning now probes the owner + account's state: a fresh walled boot with no transport and no federated + sign-in is **silent** (the operator's own first-account creation arrives + verified — the case this closes), while an owner account that already + exists **unverified**, a populated store whose bootstrap window is spent, + and an unanswerable probe keep warning. A settled deployment whose owner is + verified stops re-warning on every boot. + + `@objectstack/types` gains `isEmailVerifiedUserRow` — the [#11343] + fail-closed verified-representation allow-list, moved from + `plugin-security`'s private copy so the elevation gate and the boot + diagnostic read ONE resolution (`plugin-security` now consumes it; no + behaviour change there). + +### Patch Changes + +- 8064e6d: `POST /api/v1/auth/admin/has-permission` now answers an ObjectStack platform admin from the ADR-0068 platform-authz predicate. The vendor evaluated this permission query on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a genuine platform admin was answered `success: false` — indistinguishable from a plain member. The route is now shaded by an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (an ungranted or unknown permission still answers `false`), while anonymous callers, plain members, and every request body the vendor refuses to evaluate are delegated to the vendor unchanged, byte for byte. +- 6dd3e69: fix(auth): authorize before the break-glass guard on `POST /api/v1/auth/admin/remove-user` (#11477) + + The break-glass last-local-credential guard is registered as a global better-auth + `hooks.before`, which runs ahead of an endpoint's own middleware. On + `/admin/remove-user` — served directly by better-auth's router, whose + `adminMiddleware` establishes only a session, with the role decision landing + later inside the vendor's handler — that ordering let the guard's lookup and its + distinctive refusal be reached by any **authenticated** caller before either + authorization layer had run. Because that refusal is target-dependent, the + refusal itself carried a per-record fact about a user the caller was not + entitled to ask about. + + `/admin/ban-user` already ran the same guard **after** authorization: #9652 + shades that path with an ObjectStack raw mount whose platform-admin gate fires + first. One guard, two routes, opposite orders, and nothing asserting either. + + `/admin/remove-user` now carries the same shading, converging the whole + `/admin/*` family on **authorization before the guard**. The mount reuses the + landed #9652 / #9653 pattern and introduces no new mechanism. + + What changes is **when** the guard decides, never **what** it decides: + + - an anonymous caller still gets `401 UNAUTHENTICATED`; + - an authenticated non-admin now gets `403 PERMISSION_DENIED` for every target, + so the guard is unreachable before authorization and its answer no longer + varies with the named user; + - a platform admin is unaffected in every respect — the mount **delegates** into + better-auth rather than re-implementing removal, so the path-keyed hook still + fires and the guard still refuses the removal of the last local password + login, and admission remains the vendor's own decision (#9969). + + An ordering pin ships with the fix so the sequence is mechanically checkable + rather than re-argued: it asserts that one authenticated non-admin naming two + different targets receives **indistinguishable** responses, and — so the pin + cannot be satisfied by deleting the guard — that an admitted platform admin + still hits the guard's refusal, and still succeeds on an ordinary user. +- 2efa1e1: Apply the workspace's SAVED auth settings at boot — `AuthPlugin` now declares + the settings ordering edge instead of reading in the pre-bind window + + `SettingsServicePlugin` registers the `settings` service in `init()` but binds + its DATA ENGINE from a `kernel:ready` hook it registers in `start()`. Between + those two moments the service is resolvable and answers reads — from an empty + in-memory fallback and the manifest defaults, with `source: 'default'` — while + the deployment's real `sys_setting` rows sit unread. Nothing distinguishes that + from "no row exists". + + `AuthPlugin` was reading inside that window. Its `start()`-registered + `kernel:ready` hooks reach `getService('settings')` at depth 3 (`runBackfill` → + `ensureAuthSettingsBound` → `bindAuthSettings`) and call + `getNamespace('auth')` in the same tick. Handlers fire in registration order, + registration order is `start()` order, and `AuthPlugin` declared + `dependencies: ['com.objectstack.engine.objectql']` and nothing about settings + — so nothing ordered it after the settings plugin. + + On the shipped composition that order was not merely unconstrained, it was + **wrong**: `os serve` does `kernel.use(new AuthPlugin(...))` before the + capability loop registers `SettingsServicePlugin`, and `resolvePluginOrder` + preserves insertion order for plugins with no edge between them. So everything + `applySettings()` derives was computed from DEFAULTS at boot — the ADR-0093 + membership policy the D6 backfill runs under, and the `google_*` social-provider + config. `settings.subscribe('auth', …)` only re-applies on a *later* change, so + a workspace that configured auth in Setup and never touched it again kept + booting with the wrong values: authored, stored, and silently not applied. + + The repair is one declaration, the same shape the three other shipped readers + (`plugin-email`, `service-sms`, `service-storage`) already carry: + + ```ts + optionalDependencies = ['com.objectstack.service.settings']; + ``` + + SOFT, not hard — a kernel with no settings service must still boot auth, and + `bindAuthSettings` already returns early when the service is absent. + `requiresServices` would not have done it: that asserts the service is + REGISTERED before `init()`, which it always is, and carries no `start()` + ordering. + + Enforced in both directions. `check:settings-bind-window` goes green with the + `com.objectstack.auth` entry **deleted** from its shrink-only ledger — deleting + it while the defect stood reproduces the finding, so the green is a measurement + rather than a suppression. And `auth-settings-ordering.pin.test.ts` resolves a + hostile registry that composes auth BEFORE settings, then removes the + declaration from a live instance and watches the order revert (ADR-0049: + a declaration nothing acts on is the defect, not the fix). +- 56d3c7a: `/delete-user` no longer lets a body-supplied `userId` win over the resolved actor + + `/delete-user` is the vendor's self-service delete: its contract names no + target, the subject IS the authenticated caller. The break-glass + last-local-credential guard's target resolution on that route still preferred + a body-supplied `userId` whenever one was present, so any authenticated + caller could steer the guard's own refusal at a user other than themselves. + + The guard's target on `/delete-user` is now the resolved actor unconditionally + — `body.userId` is never consulted for that route, only as a prior fallback. + `/admin/remove-user` and `/admin/ban-user` are unaffected: target-naming is + their own contract and is untouched here. For every caller acting on + themselves, nothing changes — the same lookup runs and the same outcome + (refuse the last local credential, admit everything else) is returned. +- d3bee87: **SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. + + `AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout. + + The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one. + + **Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows. + + **Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed. +- 0e0bf80: fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations (#11770) + + Under the `invite_only` audience posture that #11739 made the default, an + administrator could send an invitation and the invitee's account creation would + still be refused with `SELF_REGISTRATION_CLOSED` — silently, with no signal to + either party — as soon as the environment held more than 200 concurrently + pending invitations. A 500-employee onboarding is an ordinary way to reach that. + + `AuthManager.hasPendingInvitationFor` answered "does this address hold a pending + invitation?" by reading at most 200 rows filtered only on `status = 'pending'` + and scanning them in memory for a case-insensitive email match. Past the first + page the invitee simply was not there, so the fail-closed `catch`-alike branch + applied the posture and refused a legitimate invitee. + + The address now goes into the query — `sys_invitation.email` carries a declared + index — and the page chain is exhausted, so no row count can hide a live + invitation. A page is "pending invitations addressed to this one person", which + better-auth bounds by refusing a second pending invitation per organization, so + this is not a read of the environment's pending population on the sign-up path; + in practice it is a single indexed lookup where the old code always read 200 + rows. + + The in-memory scan existed on the stated belief that invitation addresses are + stored as the inviter typed them. Measured against the installed better-auth + 1.7.1, that is wrong on both halves — `organization/invite-member` lowercases + the address before storing it, and `internalAdapter.createUser` lowercases the + registrant's before calling `validateUserInfo` — and the vendor's own + `findPendingInvitation` / `listUserInvitations` / `findMemberByEmail` all query + with `email.toLowerCase()`, so a mixed-case row was never redeemable through + `accept-invitation` anyway. + + The row-side comparison is kept rather than deleted: `=` folds case on some + collations and folds accents with it, so every returned row is re-checked + against the normalized address — a case-only difference still matches, an + accent-only difference does not. Expiry stays in JS so a row with no readable + `expires_at` keeps reading as live. The security properties are unchanged: + `status = 'pending'` only, expiry still enforced, and an unanswerable probe + still means no carve-out. +- 7131f12: **Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). + + ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. + + **What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: + + - an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; + - a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; + - an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. + + **One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. + + **The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. + + Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. +- 2cf5a96: An organization no longer stops accepting members at 100 — membership is not a + limited axis, and the ceiling nobody chose is now stated explicitly + + A customer adding users was refused with `Organization membership limit + reached`. Nothing in this codebase set that ceiling: better-auth's organization + plugin substitutes a vendor default of **100** for an absent `membershipLimit` + (`count >= (membershipLimit || 100)` in `routes/crud-members`), and + `auth-manager` passed `organizationLimit` — how many organizations one user may + CREATE — while never passing `membershipLimit`, which is a different question. + + The two read almost identically in a config block and mean nothing alike, which + is why the gap survived: the option that WAS set looked like the option that + was not. In the field the refusal is worse than merely wrong — it arrives while + an operator is looking at licences and seat counts, and reads as an entitlement + problem on an axis that carries no entitlement at all. Seats are metered on AI + usage; plain membership has never been billed. + + - `membershipLimit` is now passed explicitly, defaulting to unbounded. + - `OS_ORG_MEMBERSHIP_LIMIT` is the opt-in for a deployment that DOES want a + ceiling (a pilot, a trial tenant). Unusable values (empty, non-numeric, + zero, negative) read as unset rather than as a cap — a typo must not be the + thing that locks an organization, which is exactly the failure mode being + fixed. + - The decision lives in `resolveMembershipLimitOption()` rather than inside the + plugin-construction expression, so it is testable: the unset case, the + explicit ceiling, the unusable-value direction, and — deliberately — that the + chosen value clears the vendor's 100 by a wide margin. If a future + better-auth changes that default, the test says so instead of leaving an + unexplained constant behind. + + The unbounded value is `Number.MAX_SAFE_INTEGER`, not `Infinity`: the option is + compared numerically but also travels through option plumbing that may assume a + finite value, and nine quadrillion members is unlimited by any measure that + reaches a real deployment. +- 3c0f3ea: docs(plugin-auth): state what the `session.cookieCache` door costs, measured against better-auth 1.7.1 (#12547) + + No behaviour change, no API change, and ⛔ **no boot refusal** — the #4785 + posture stands on both doors: deliberate opt-in with the cost disclosed + (maintainer ruling 2026-08-27). `cacheSecondaryStorage()` stays exported and + `AuthManagerOptions` accepts exactly what it accepted before. + + This is shipped as a changeset rather than left in source comments because the + CHANGELOG is where the sibling door's posture already lives — *"remains + exported for anyone who wants better-auth's cached session store deliberately. + It now says plainly what it costs."* An operator weighing `cookieCache` reads + the changelog, not our TSDoc, and one door's cost being on the public record + while its sibling's is not is the asymmetry this closes. + + **What was measured**, against the installed better-auth `1.7.1` (read out of + `node_modules`, never off the `^1.7.1` range): + + - **The failure direction is the sibling's.** With `cookieCache` enabled, + `/get-session` answers from a signed payload in the client's own + `session_data` cookie and returns before any adapter read. ObjectStack + revokes by writing the `sys_session` row (ADR-0069 D4) and hides tombstoned + rows from better-auth's reads — all read-path enforcement, so while the + cookie answers, a revoked session keeps authenticating and nobody gets an + error. + - ⭐ **The reach is materially smaller, and the disclosure says so rather than + inheriting the sibling's wording.** The session of record does not move — + `createSession` still writes the row, so admin session lists, the + concurrent-cap count and D4's audit trail stay correct. The staleness window + is bounded and per-client (`cookieCache.maxAge`, default 300s) and cannot be + extended without a database read, because better-auth force-disables its + stateless `refreshCache` whenever a `database` is configured — which + ObjectStack always does. Sensitive operations already bypass it via + better-auth's own authoritative re-read. `secondaryStorage` has none of these + three bounds. + - **It is not reachable from ObjectStack config today, by construction rather + than by refusal.** The spec's `AuthConfigSchema.session` declares + `expiresIn` / `updateAge` only and `createAuthInstance` reads only those two, + so a `cookieCache` key is dropped rather than honoured. The one way in is the + `authInstance` escape hatch, where the host has replaced the whole config. + + The disclosure lands at `auth-manager.ts`'s `session:` block — the place a + future author would plumb the key — with a pointer from `secondary-storage.ts` + so the two doors are described together. An **observation** pin in + `session-of-record.test.ts` records the drop end-of-chain: a revoked session + still de-authenticates on the very next request. ⛔ It pins no refusal; it is + the tripwire a paragraph alone could not give, so the day someone plumbs + `cookieCache` through, a red test points at the cost note instead of D4 + quietly acquiring a revocation window nobody chose. +- 26deb31: fix(plugin-auth): keep the logger's receiver when selecting a log channel — class-based host loggers no longer crash, so audience refusals report their verdict instead of `500 null` (#12773) + + Three sites picked a log channel by **extracting** the method before calling it: + + - `auth-manager.ts` — `(logger?.error ?? logger?.warn)?.(message, meta)` in `audienceLogError` + - `reconcile-membership.ts` — `const log = deps.logger?.error ?? deps.logger?.warn` in `refuseInvalidPolicy` + - `adopt-membership.ts` — `const log = options.logger?.info ?? …` in `adoptExistingMembership` + + `a.b` in *call position* passes `a` as the receiver; `(a.b ?? c.d)(…)` evaluates to + the bare function first, so the call runs with `this === undefined`. A plain-closure + logger does not read `this` and survives it. `@objectstack/core`'s `ObjectLogger` is a + real class with prototype methods and no constructor binding — `error`/`fatal` reach for + `this.writeErrorLike`, `debug`/`info`/`warn` for `this.write` — so it threw: + + ``` + TypeError: Cannot read properties of undefined (reading 'writeErrorLike') + at error (.../packages/core/dist/index.js:650:10) + at _AuthManager.audienceLogError (.../plugin-auth/dist/index.mjs:5460:38) + ``` + + In `validateAudienceAdmission` the damage compounded: the throw from the `try` landed in + the `catch`, which called the same helper again, so the second throw escaped the gate. + An audience refusal — a decided, fail-closed 4xx naming exactly what the operator had + misconfigured — was delivered to the client as **`HTTP 500` with a null body**, and the + verdict reached neither the caller nor the log. + + Each site now calls through the **property** while keeping its fallback, so both halves + of the contract hold: the `error` → `warn` degradation (#9754) and the receiver. + + No behaviour changes for a host whose logger is a plain closure object — that shape + worked before and is pinned unchanged. The gate's decisions, codes and messages are + untouched; only their *delivery* is repaired. + + The regression pin (`logger-receiver-detach.test.ts`) uses a **class-based** logger + double whose methods dispatch through `this`, because a closure double passes against + the broken code and pins nothing; its case ⓪ asserts that receiver-sensitivity directly + so the file cannot quietly become vacuous. +- c0714eb: Walled platform-admin elevation now requires the owner-email match to be + VERIFIED, and the bootstrap re-runs on the verifying update (#11343) + + Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched + the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on + `sys_user` — with no `email_verified` condition, while email verification is + off by default. #11211 narrowed elevation from "whoever registers first" to + "the declared owner's address" (a real and large narrowing); this closes the + remainder that card #11343 records: in the window before the owner registers, + an account created with the owner's address would still be elevated. + + Two halves, deliberately in one change: + + 1. **The elevation match requires `email_verified`** (fail-closed allow-list + over driver representations; an absent field on an imported/legacy row + reads as unverified). An unverified holder of the owner's address is + refused like any stranger — new reason `walled_owner_not_verified`, logged + loudly with the unblock in the line. Never falls back, same direction as + the undeclared-owner refusal. + 2. **The bootstrap-replay middleware now also fires on `sys_user` updates + touching `email_verified` / `email`** (trigger set extracted as + `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). + Verification is an UPDATE — with the old insert-only replay, requiring + verification would have refused the genuine owner at sign-up and then + never looked again, leaving the platform without any administrator. + + `single` posture is untouched both ways: first-user promotion (ruled + reasonable in #11184) does not gain a verification requirement, and the + owner-email variable is still never consulted there. Both directions are + pinned: the unverified holder is refused AND the verified owner is elevated — + including across the refuse-then-verify-then-re-run sequence. + + The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with + `email_verified` stamped: it is created by the deployment's own boot command + with operator-known credentials — the same trust shape as a trusted-SSO + insert, not an unknown self-registrant — so walled dev/harness boots keep a + promotable declared owner. The generic sign-up path is unchanged. +- bf8d129: feat(plugin-auth): warn at boot when a walled deployment declares an owner it can never verify (#11640) + + A walled deployment (`OS_TENANCY_POSTURE=group|isolated`) that declares + `OS_PLATFORM_OWNER_EMAIL` but wires **no verification path** — no email + transport and no trusted federated sign-in — now emits a loud, named boot + warning (`walled_owner_no_verification_path`) on `kernel:ready`. + + Since #11343, walled platform-admin elevation requires the declared owner's + address to be **verified**, and verification can only arrive by an emailed + link or by a federated sign-in that inserts the account already verified. With + neither wired, the declared owner registers, is refused + (`walled_owner_not_verified`), and has no in-product way to satisfy the + condition — a dead end that previously surfaced only weeks later, at the + owner's rejected registration. The warning names both missing inputs and the + concrete wiring for either remedy (an email service, or SSO / a social + provider), since either one alone clears it. + + ⛔ **Boot proceeds — this is not a refusal**, and no accept/reject behaviour + changes anywhere: the walled + undeclared-owner boot refusal (#11184) and the + fail-closed elevation refusal (#11343) are untouched. Deployments already + wiring either verification path see no new output, and neither does a + dev/harness boot whose declared owner is the dev-admin the seed provisions and + stamps verified. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [6747718] +- Updated dependencies [340c5e5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [7181101] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [d81838c] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [3519f8d] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [8965398] +- Updated dependencies [6e33394] +- Updated dependencies [7986d97] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [1e4d2eb] +- Updated dependencies [b853cf3] +- Updated dependencies [4af6c44] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 007f070c2d..587ead752a 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.2.0", + "version": "17.3.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 20c793372d..b48d8a7869 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,281 @@ # @objectstack/plugin-dev +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [6747718] +- Updated dependencies [340c5e5] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [7ef0268] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [56d3c7a] +- Updated dependencies [8a483b3] +- Updated dependencies [7181101] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [aa3f9ba] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [30928a6] +- Updated dependencies [de47336] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [d81838c] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [31bb2e7] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [db39dfc] +- Updated dependencies [3c0f3ea] +- Updated dependencies [26deb31] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [6a571d3] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [d48929e] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [f4e741b] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [3519f8d] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [8965398] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1e4d2eb] +- Updated dependencies [b853cf3] +- Updated dependencies [4af6c44] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [a41069b] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [c94be62] +- Updated dependencies [96940b7] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [2ff01cf] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [4d5b4f8] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/service-storage@17.3.0 + - @objectstack/service-realtime@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + - @objectstack/account@17.3.0 + - @objectstack/setup@17.3.0 + - @objectstack/service-i18n@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index e5157d8395..2a3fd57ac2 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.2.0", + "version": "17.3.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 201a0e0f9a..d3ff2ba997 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,204 @@ # @objectstack/plugin-email +## 17.3.0 + +### Minor Changes + +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. + +### Patch Changes + +- 47d9b77: The SMTP port range `1-65535` is now declared once and the refusal is generated from it (#12993). It had been hand-written three times across two packages: the enforcement in `SmtpTransport`, the `(expected 1-65535)` literal in the very next line's message, and `min: 1, max: 65535` on the mail settings form's `smtp_port` field — which `@objectstack/service-settings` really does enforce (`declaredBounds` / `validatePatch`), so it is a second door rather than decoration. The first two were adjacent lines, the cheapest possible drift: changing the check without changing the sentence yields a refusal that misstates its own rule, and nothing fails. + + `transports/smtp-port-contract.ts` now owns `SMTP_PORT_MIN` / `SMTP_PORT_MAX`, the predicate that applies them and the sentence that states them. The message text is **generated** rather than re-spelled, so the second spelling no longer exists to drift — the stronger of the two repairs the card named, since it deletes the drift instead of checking for it. + + Nothing is accepted or refused that was not before. The move is pinned as behaviour-preserving against the previous inline expression, kept verbatim as the oracle, over a table that includes both edges, the non-finite values and the non-integers. + + The settings manifest keeps its own numbers deliberately. `@objectstack/service-settings` does not depend on `@objectstack/plugin-email`, and the plugin depends on the service only as a test-only devDependency; making the manifest import the constant would add a runtime edge from a service to a plugin, invert the layering and pull `nodemailer` into the settings service's install closure. So the two are held equal by a cross-package assertion over that existing devDependency instead — the same mechanism that already holds the provider dropdown equal to `EMAIL_TRANSPORT_PROVIDERS` — and no new dependency edge is created in either direction. + + This range is **not** the CLI's listen range and must never be merged with it: `os serve` floors at `0` because port 0 asks the OS to choose one to listen on, while an SMTP port is a destination and floors at `1`. Collapsing them onto one constant would silently make `0` a legal SMTP port, so the floor is pinned explicitly. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 01c9a88251..2919690500 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.2.0", + "version": "17.3.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 74d40848ce..1736654892 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/plugin-hono-server +## 17.3.0 + +### Minor Changes + +- 6a571d3: feat(cli,plugin-hono-server): declare `exports` maps, and make "a published package declares one" a gate (#12879) + + **BREAKING** removal of reachable subpaths, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + **FROM.** Both packages declared `main` + `files` and no `exports`. Under Node's + resolution that leaves every module under `dist/` importable from outside the + package, whatever the entry barrel names — `@objectstack/cli/dist/utils/lower-callables.js` + resolved, and so did every other `dist/**` path in either package. They were the + only two of the 69 publishable packages here in that shape. + + **TO.** Each declares exactly the entry it means to offer, and nothing else: + + ```jsonc + // @objectstack/cli — ESM-only (tsc, "type": "module") + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } + + // @objectstack/plugin-hono-server — dual build (tsup esm+cjs) + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } } + ``` + + The root entry resolves to exactly what `main` / `types` already pointed at, so + `import … from '@objectstack/cli'` and `require('@objectstack/plugin-hono-server')` + are byte-for-byte unchanged. `@objectstack/cli` uses `default` rather than `import` + on purpose: it is ESM-only, and an `import`-condition-only map would ALSO refuse + CJS `require()`, which is a second break this change is not making. + + The CLI's `bin` is untouched. `exports` gates specifier resolution only, and the + executable is reached by path — the `objectstack` / `os` shims, or + `node node_modules/@objectstack/cli/bin/run.js` as the Dockerfiles and the + showcase Playwright config spell it. + + **Migration.** A consumer that deep-imports `@objectstack/cli/dist/**` or + `@objectstack/plugin-hono-server/dist/**` now gets `ERR_PACKAGE_PATH_NOT_EXPORTED`. + Import the symbol from the package root instead. If it is not exported there, it + was never an offered surface — the deep path resolved by omission, not by + decision, and the fix is an issue naming the use case rather than a map entry + that would ratify it (ratifying accidental reachability prices every later + internal refactor of these packages at a minor bump — the trap this change + exists to close, in the other direction). + + Measured before the maps were written: **one** in-repo deep importer, + `packages/qa/dogfood/test/build-shaped-artifact.ts`, which now reads the CLI's + `lowerCallables` as SOURCE by relative path — the shape the rest of that suite + already uses for package-internal reads, and one that keeps the util internal + (#6293: reach the goal without growing `@objectstack/cli`'s public entry). + `@objectstack/plugin-hono-server` had **zero**. ⚠️ Deep importers OUTSIDE this + repo cannot be measured from inside it; they are the residual risk of this + release, and they break at the import rather than silently. + + **The class, not the two instances.** `pnpm check:published-files` gains a sixth + invariant — GATED: a publishable package declares an `exports` map — turning a + convention that held for 69 of 71 into a written ratchet. It carries the census + control the ruling requires: the gate reads a POSITIVE signal off every manifest, + so a broken reading (no members enumerated, a key read under the wrong name, a + parse that drops manifests) would make "nobody violates GATED" true and green. + A census that finds nobody declaring `exports` therefore fails as an INSTRUMENT + error, in its own words, and the self-test holds the floor inside a band against + the live tree in both directions. + + + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 42f82145f8..9e585a8bb3 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.2.0", + "version": "17.3.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 0ecb6c65cc..d68f9ce75a 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/plugin-pinyin-search +## 17.3.0 + +### Patch Changes + +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [f6fa22c] +- Updated dependencies [0010797] +- Updated dependencies [0db5520] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [d395692] +- Updated dependencies [2d5cee3] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9086761] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [49f0dcf] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [f7b25c5] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index a4cec2237e..34bb854a9f 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.2.0", + "version": "17.3.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 7f7095a3d8..ed094e8a27 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,184 @@ # @objectstack/plugin-reports +## 17.3.0 + +### Patch Changes + +- a1c804b: Report CSV and HTML exports now infer their columns from **every** row of the result set, not from the first 50. Column inference sampled `rows.slice(0, 50)` while the projection it produced was applied to all rows, so when a report declared no explicit `query.fields`, any key whose first occurrence fell at row 51 or later was absent from the header *and* dropped from every row that carried it. The export gave no signal: the CSV was well-formed, every row had the same arity, and nothing marked a column as inferred rather than declared, so a recipient of a scheduled report attachment could not tell. Sparse columns are the normal shape of report output — an optional field, a formula only some records satisfy, a lookup that resolves for a subset — and the sampled prefix is the query's first page in its own `orderBy`, so for a report sorted by status or created date the sample correlated with exactly the column it dropped. Both affected renderers are fixed (`csv`, which is also the `default:` format branch, and `html_table`); `json` was never affected. Already-inferred columns keep their position and late-appearing ones are appended, so an export that was correct before is byte-identical now. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 330ebcc98d..66d7aaffa4 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.2.0", + "version": "17.3.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 471dc85aaf..b8db5b529d 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,1366 @@ # @objectstack/plugin-security +## 17.3.0 + +### Minor Changes + +- 6171331: fix(plugin-security): `controlled_by_parent` composes across a chain — a child whose master is itself derived is no longer readable and writable org-wide (#11082) + + **BREAKING** access tightening, shipped as `minor` under the repo's + launch-window convention. It denies reads and writes that previously + succeeded — which is the whole point: they were never authorized by any + declaration, and the app author could not tell. + + `controlled_by_parent` (ADR-0055) resolves a detail's access from its master. + #5386 made that resolution fold in the master's ownership and its + `sys_record_share` grants, not just the master's RLS policies. It did not + recurse, and both halves it composes answer "no restriction" for a master that + is **itself** `controlled_by_parent`: + + - the RLS half is `null`, because a derived object authors no policy — + declaring `controlled_by_parent` *is* its policy; + - the sharing half is `null` too: `plugin-sharing`'s `buildReadFilter` opts out + of every model that is not `private`, and `effectiveSharingModel` maps + `controlled_by_parent` to `public`. + + Composed: `null`. The derivation's master query then ran as **system** with an + empty predicate and returned **every master row**, so a two-level chain was + enforced at level one and org-wide at level two. The write half failed through + a separate mechanism with the same result: the master gate asks `canEdit` on + the master row, `checkEdit` returns `abstain` for a `public`-mapped model, and + `abstain` is not `deny` — so it answered `true` for every master row. + + Both halves now walk the chain. The read derivation composes the master's own + `controlled_by_parent` filter as a third layer, and the write gate runs its + three master-edit legs on each hop until it reaches a master that governs its + own rows. The master set is therefore point-for-point equal to what a direct + read of the master returns, at every level, which is the equality #5386 + established for one level. + + This is **not** a blanket refusal for chained declarations: a detail whose + whole chain is reachable stays readable and writable, and the single-level case + is unchanged. Two guards bound the walk and both fail **closed**, never to "no + restriction": a metadata cycle is refused, and so is a chain deeper than 8 + links (a cost ceiling, not a supported-length statement — termination is + already guaranteed by the cycle guard). + + What an app may observe: a detail under a `controlled_by_parent` master that + was reachable before is now reachable only if the caller can reach the whole + chain above it. Apps whose masters are `private`, `public_read` or + `public_read_write` — every `controlled_by_parent` object authored in this + repo — are unaffected. + + +- 25b1b81: Surface "declared ≠ enforced" on package-declared permission sets, and give + operators a sanctioned, audited way to discard a stale environment overlay. + + Field report: an rc→GA upgraded environment can freeze a package's + permission set at a stale snapshot while the shipped artifact keeps + shipping grant changes — silently, with only a boot log counter as a + signal. Two independent mechanisms can cause this, and either (or both + together) can be live on one row: + + - **overlay shadow** — a Studio permission-matrix save on a package-declared + set materializes a `sys_metadata` overlay that shadows every later package + edit to that set, forever, surviving redeploys and restarts; + - **provenance skip** — a `sys_permission_set` row whose `managed_by` column + predates package provenance tracking is treated as environment-authored + and never reconciled with the package. + + `sys_permission_set` now carries `drift_status` / `drift_detail`, recomputed + every boot, naming the set and the cause — a new "Needs Attention" Setup + list view surfaces only sets that actually differ from their shipped + artifact (an in-sync set is never flagged; `drift_status` stays `null`). + + A new "Discard Overlay" Setup action (`POST + /api/v1/security/permission-sets/:id/discard-overlay`) removes a stale + overlay and resyncs the record to the current artifact synchronously — the + supported, audited counterpart to the raw-SQL remediation the field report + had to use. It targets package-declared sets only: a set with no current + package declaration is refused, so a genuinely environment-authored set can + never be discarded by name collision. + + Boot-time auto-adoption of legacy rows and a bulk `os meta + adopt-permission-sets` command remain out of scope (2026-08-20 maintainer + ruling) — the manual SQL adoption recipe stays documented for the rc→GA + provenance-skip case; see the ops runbook. +- e170b0a: Lock package-declared permission sets at the save door; clone to customize (#11513) + + Maintainer ruling of 2026-08-24, recorded verbatim and untranslated: + 「同意 第一步(创业阶段,Salesforce 式)」 — step 1 of the mainstream-platform + comparison: lock the base, clone to customize. + + A Studio/API save that targets a **package-declared** permission set is now + **refused at the server**, with a message that names the sanctioned path — clone + it and edit the clone. Previously the data door translated the write into a + metadata write and left the refusal entirely to the metadata protocol's ADR-0005 + tier gate. That gate is exactly what the documented + `OS_METADATA_WRITABLE=permission` operator hatch switches off, so on a + deployment running with the hatch there was no refusal at all: the save minted a + `sys_metadata` overlay of a packaged set, and boot reconciliation re-projected + that overlay onto the record on every boot, unconditionally, forever — the set + froze at the fork and every future package upgrade of it was ignored, silently. + + **Clone-to-customize** is the sanctioned path and is unchanged: the clone is an + ordinary org-owned set (`managed_by: 'admin'`, no `package_id`, so no upgrade + linkage), and upgrades keep flowing to the package-declared base untouched. + + **Existing forks** get a **detection reading** at boot — count *and names*, + warned loudly, saying outright that nothing was reaped. It reads `sys_metadata` + directly rather than the `customized` column, which is forced `false` on the + exact confounded shape the field report measured (a genuinely package-declared + set whose row's `managed_by` predates provenance tracking). Nothing is reaped, + merged or migrated: disposition of an existing fork is a follow-up reading for + the maintainer, and the per-set remedy remains the explicit, audited + "Discard Overlay" action a human invokes. + + Behaviour deliberately NOT narrowed: + + - an **ordinary org-owned** set is still fully editable (pinned as a control — + a lock that refuses everything would satisfy the refusal pin perfectly); + - the **activate / deactivate** actions still write their column: a bare + `{ active }` patch is row state, not a customization of the definition; + - a `managed_by: 'package'` row with **no artifact behind it** — published + through the metadata door (ADR-0070) and materialized by the ADR-0086 P2 + path — keeps editing in place. That is ADR-0094 D5-R's surviving + `allowRuntimeCreate` neighbour, and `managed_by` is measurably not the + artifact-provenance fact. Provenance is read from the engine SchemaRegistry, + the one source this plugin already calls "package-declared". + + Provenance is **fail-closed**: a read that cannot answer refuses the save rather + than accepting it, and the read is not a name-keyed page over + `sys_permission_set`, so it cannot be truncated into a false "not packaged". +- a65db76: `OrgScopingEntitlement` grows two per-deployment wall-shaping keys, both declared by the mounted `org-scoping` runtime and consumed by plugin-security when arming the Layer 0 organization wall, both fail-closed (absent ⇒ byte-identical behaviour): + + - `platformGlobalObjects?: readonly string[]` — objects THIS deployment declares platform-global; Layer 0 does not wall them here (read filtering, the ADR-0123 D2 no-active-org write refusal, the forge guard, and the Layer 1 wildcard-`organization_id` policy drop all follow, because they read the same per-object security meta). Exact machine names only; a junk shape is refused loudly and exempts nothing. + - `suppressUnboundedOrgAdminGrant?: boolean` — the walled-posture `organization_admin` auto-grant hands out `organization_admin_no_bypass` (no unbounded `viewAllRecords`/`modifyAllRecords`) instead; the superseded-variant reconcile converges standing grants in both directions. + + New spec exports: `PlatformGlobalObjectsSchema`, `PlatformGlobalObjects`, `OrgScopingEntitlementSchema`. +- 5619aac: The packaged-permission-set lock now guards the metadata door as well as the data door. The pre-persistence authoring-gate seam gains a `permission` registration (`registerPackagedPermissionSetLockGate`, new export) that consults the same `classifyPackagedPermissionSet` classifier and throws the same `PackagedPermissionSetLockedError` the `sys_permission_set` write door already uses — one spelling of "package-declared", two doors, one refusal. + + What stops working, and for whom: an operator using the `OS_METADATA_WRITABLE=permission` escape hatch to save a permission set that an installed package declares now gets `403 NOT_OVERRIDABLE` (the refusal names the sanctioned clone path) instead of silently minting a `sys_metadata` overlay whose grants win at read over the package's declaration. This applies hatch open or closed, draft and publish saves alike, and also means stored-overlay maintenance passes (e.g. stored-item migration) report a per-row refusal for such grandfathered forks rather than rewriting them. + + What keeps working unchanged: hatch writes to any name no installed package declares — the hatch's documented per-org/env override capability — land exactly as before, and package-door authoring of workspace-owned sets (definitions living in `sys_metadata`; ADR-0070, ADR-0094 D5-R) stays editable. Package publishes travel the `package-author` channel, which this seam exempts by contract. +- db39dfc: feat(plugin-security): the verified platform OWNER bypasses the Layer 0 org wall (#12974) + + Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能简单点,对于超级管理员, + 配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」 + + When plugin-security arms the Layer 0 organization wall on a READ, the + `org_id` tenant filter is no longer appended for a session whose account is + the **verified platform owner** — the `OS_PLATFORM_OWNER_EMAIL` identity, + matched under the existing #11343 verified-email predicate (the SAME + comparison the platform-admin elevation gate makes, now shared through + `platform-owner-wall-bypass.ts`; server-side `sys_user` row facts only, never + a client-supplied claim). This unblocks the one account meant to be + all-seeing: metadata-driven operator screens over PUBLIC tenant objects no + longer read EMPTY for the deployment's declared owner (the cloud#1676 shape). + + The door is READ-only. WRITES keep today's behaviour for everyone INCLUDING + the owner: an org-less tenant-scoped write is still refused 403 naming the + missing active organization (ADR-0123 D2 — an org-less write would mint + exactly the NULL-organization rows the platform is eliminating), and the + by-id write pre-image read stays walled with it. + + Fail-closed in every direction, pinned: env unset ⇒ nobody bypasses (the wall + arms exactly as before, with no row I/O); email mismatch ⇒ walled; email + matches but the account is NOT verified ⇒ walled; only a verified match lifts + the read filter. The bypass lifts ONLY Layer 0 — object/field permissions, + business RLS (Layer 1) and the write `check` path are untouched — and the + door serves the single env-declared owner (no lists, no patterns). Every + wall-bypassing read computation emits a structured warn-level audit event + with the stable name `platform_owner_wall_bypass` (object, operation, userId, + suppressed filter). +- f4e741b: feat(security): the referential FK-clear write is exempt from the object-level CRUD check (#12597) + + **This changes which deletes succeed** — an observable behavioural contract + change on the delete path, which is why it ships `minor` rather than as a + patch-grade defect repair. + + Deleting a record makes the engine clear every optional lookup that points at it + (`deleteBehavior: 'set_null'`). That cleanup `UPDATE` is engine-owned referential + integrity, and it has carried the server-derived `__referentialFieldClear` marker + since #3023 — but the marker reached only the ownership-anchor guard, so the + write still had to pass the **object-level CRUD check** on the referencing + object. Consequence, measured on a real deployment across 17 role×object pairs: a + role with full delete rights on A and no grant at all on B could delete an A only + while B was **empty**. The moment a real row referenced it, the delete failed with + one generic "you do not have permission", and nothing on any permission screen + showed that deleting A also required write authority on B. + + **What is exempt: the object-level CRUD grant check, and nothing else.** A marked + `update` skips that one gate (both the caller's grant and the ADR-0090 D10 + delegator half of the same question). Everything else in the security middleware + runs unchanged and is pinned test-by-test: + + - field-level security on the FK column still refuses; + - the RLS `using` row scope on the referencing object still refuses; + - the RLS post-image `check` still refuses — so a deployment declaring + `product != null` keeps getting a truthful refusal instead of a silent clear; + - declared validation rules keep firing (they were never in this path); + - a caller without delete rights on the target is still refused; + - an ordinary, unmarked update on the referencing object is untouched. + + ⛔ Deliberately **not** `isSystem`: that bypass is total (see + `content/docs/permissions/system-context.mdx` — "Elevation is total, and it is not + granular"), and it would have switched off all three guards above. ⛔ The + `cascade` arm — deleting whole referencing rows — is **unchanged** and still + requires the caller's own delete authority on those rows. + + The write is not elevated at all, so audit attribution is unchanged: the cleanup + `UPDATE` still runs under the operator's identity and lands in the ledger as that + operator (`user_id` / `actor`, and the `updated_by` stamp). + + No authorable surface changes, and no metadata needs migrating: a deployment that + was working around this by granting write access on referencing tables can narrow + those grants, but nothing forces it to. +- 4d25d22: **BREAKING (platform object removed):** the `sys_scim_provider` platform object is retired (#11757, ruled on #11693 — leg 1a of the #11632 SCIM epic). + + FROM → TO, per surface: + + - `SysScimProvider` (export of `@objectstack/platform-objects` / `.../identity`) → removed, no replacement export. Fix: delete the import. Stable SCIM state lives on the seven `sys_scim_*` stable-model objects (#3653), and connection credentials on `sys_scim_connection_credential`. + - `sys_scim_provider` in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) → removed. `isPlatformProvidedObjectName('sys_scim_provider')` is now `false`, so a stack referencing the name is flagged as a probable typo instead of resolving. + - plugin-auth: the object is no longer provisioned, and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry — the installed stable `@better-auth/scim@1.7.1` derives no such model, so the entry bridged nothing. + - plugin-security: the `BETTER_AUTH_MANAGED_OBJECTS` write-deny entry for it is gone with the object (the list is pinned bidirectionally against `managedBy: 'better-auth'` declarations). + + The rc.1-era row was written only by the retired `/scim/generate-token` endpoint; after the stable-1.7.1 migration (PR #12726) nothing could write to it. Per the maintainer's ruling (2026-08-24, 「不需要考虑历史数据」; reaffirmed 2026-08-25 — SCIM has no real customers), **no data migration ships**: existing `sys_scim_provider` tables in deployed databases are left untouched — no backfill, no reaper, no migrate command. SCIM-enabled deployments re-register connections on the stable surface; the IdP token reissue is a migration-day operator action regardless of this change. + + The ADR-0066 D3 capability-gate pin moves from the retired object to its surviving sibling `sys_sso_provider`, so the gate posture stays test-pinned. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`) and the #12726 precedent on the same ruling. + + +- 366f895: feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) + + The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, + `/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, + replaced by seven new models and a three-way connection contract. This lands + the migration atomically: + + - **Seven new platform objects** back the stable models — + `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, + `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, + `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, + registered in the platform-object-names registry, listed in + `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose + `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group + provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — + is closed). + - **SCIM connections stay runtime data.** The stable constructor is satisfied + with an application-owned `authentication.verifyBearerToken` that resolves + the connection from a row at request time — not static boot config, and not + the upstream `managedConnections` catalog (deliberately not adopted). + - **ObjectStack owns SCIM credentials outright** (stable upstream stores no + credential at all): `sys_scim_connection_credential` plus + `scim-connection-service.ts` mint/digest/verify. At rest only an + HMAC-SHA-256 keyed by the deployment auth secret (base64url, + domain-separated) is stored — at parity or better than the rc.1 unsalted + SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live + 401 paths for forged, revoked and expired bearers. + - **The ObjectQL better-auth adapter gains native transactions** + (`engine.transaction`, fail-closed on drivers without `beginTransaction`), + which stable scim requires by assertion for atomic provisioning writes. + - **Scaffold suppression retired**: the `@better-auth/scim>better-call` + `allowedVersions` entry (CLI renderer + blank template) is gone — stable + 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped + to absence pins. The `better-auth>better-sqlite3` and four + `@better-auth/utils` entries stay; their retirement conditions are separate + and unmet. + - The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers + `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace + overrides' silencing would "satisfy" while the family is 1.7.1. Floating is + its own follow-up. + + **Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token + endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — + but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a + documented "do not let the IdP push groups" boundary, and the maintainer ruled + (2026-08-25) that SCIM has no real customers and old data need not carry: the + one binding constraint is that an existing system upgrades smoothly, which it + does — every table the installed library can write exists at this version, and + SCIM-disabled deployments see no behavior change. A major would move the whole + fixed version group for a feature surface with zero consumers. Deployments + that had SCIM enabled must mint new connection credentials (digests are not + portable from rc.1 on any path — IdP token reissue is a migration-day + operator action regardless of semver level). `sys_scim_provider` itself is + NOT removed here; its retirement is tracked separately (#11757). +- 18b53ac: `SecurityPlugin`'s own report sink is now **console-backed by default** — loud until a host + injects one — instead of being initialised to an empty object. Its fail-closed refusals + (`getReadFilter … denying (fail-closed, #2852)` and `#4467`, `checkAuthoredRowWrite … + abstaining`, the ADR-0123 tenant-wall refusal) previously went nowhere at all on any instance + whose lifecycle had not yet reached the sink binding; they now reach `console.warn` / + `console.error`. A host that injects a logger is unaffected: `start()` assigns `ctx.logger` + over the default, above both of its early bail-outs (#10706), so a degraded boot still reports + through the host. + + **Operator-visible:** a deployment that never injects a sink will begin seeing these refusals + on the console. That is the intended change — the refusal itself is not moving, only whether + anyone can see it. + + Why `minor` and not `patch`: the observable output of a running deployment changes. The + declared shape changes with it — the field's `warn` channel is now non-optional, which is what + #9754 requires of a sink declaring an optional `error`, and what a default of `{}` made + impossible to state honestly. `error` deliberately stays optional (#9754 option C, falsified: + hosts do inject reduced sinks). The maintainer ruled on 2026-08-24 (#10556) that the default + becomes console-backed and that silent-by-declaration is rejected. +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + + +### Patch Changes + +- 0e4e51b: feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992) + + + + The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE + carry-over contract instead of a rendering convention: a param may state, in + metadata, that its value is carried through the action dialog rather than + collected from the user. + + - `carryOver: true` — seed from the current row (`defaultFromRow: true` is + required alongside, enforced at parse time), render as a NON-EDITABLE + summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer, + which omits the param from the submission entirely — a carry-over param is + always sent. + - Aliases: `readonly` / `disabled` are refused with guidance naming + `carryOver` (a field's `readonly` means write-path strip, which is exactly + the wrong half here). + - Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set` + JSON facet params (`object_permissions`, `field_permissions`, + `system_permissions`, `row_level_security`, `tab_permissions`) declare it, + so the sanctioned clone path stops offering five prefilled raw-JSON + textareas an admin could hand-mangle into a clone that grants MORE than its + base. `description` stays an ordinary editable param. The send-side contract + is unchanged (#11703 pin 6 stays green). + + The objectui renderer leg (honouring the declaration in `ActionParamDialog`) + is the downstream card tracked on #11753. +- 4bd6faa: feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) + + The substrate step (§10.3) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation + machinery once, before the grants cache (#11967) that will consume it, so that + leg does not carry it. **Nothing here caches anything.** + + - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine + middleware seam on every `insert` / `update` / `delete`, ahead of the whole + chain (and so ahead of any `isSystem` bypass a middleware applies). It + generalises the private counter `@objectstack/plugin-security` has carried + since #10757: the mechanism was always the engine's, and hoisting it lets a + second consumer share **one** signal instead of minting a parallel one that + watches a different set of writes. A seam rather than a list of call sites, + because a forgotten call site fails as silent over-permission and writing + through the engine is the only way to write at all — including better-auth's + own adapter. + - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged + in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a + consuming cache carries is the correctness contract; this channel is not.** No + shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a + missed message is *expected*, the bridge stays out of the write path (a + publish failure is logged and swallowed, never awaited by the writer), and the + channel only moves the *typical* convergence from one TTL to one network hop. + That statement lives in the code at the channel, where a consumer reads it. + - **The boot-time posture statement** — non-optional by the ruling. Whenever a + grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no + cross-node invalidation bus, the deployment is told so at `warn`, every boot, + naming the window it accepted and the remedy. It is a statement, not a + refusal: a TTL-bounded per-process cache is a legitimate configuration. It is + said out loud because a silently-absent invalidation bridge is how a security + control gets disabled with nobody noticing (#4785). The in-process `memory` + driver counts as **no** bus — a cluster service exists on the shipped default + while fanning out to nobody, which is the case a "is a cluster service + registered?" check answers `yes` to and is wrong about. + + **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero + subscribers, so nothing is published and nothing is invalidated; with the + shipped default TTL of `0` the bridge attaches nothing and logs nothing above + `debug`. The one composition change worth naming: `Runtime` now registers + `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` + — that is not an oversight, it is the loudest case the posture check has, and + skipping it there would put the statement's absence exactly where the missing + bus is. + + `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads + the engine's epoch when the wired engine exposes one and keeps its private + counter otherwise (test doubles, embeddings). The covered set of writes is + identical — the plugin's own middleware was already global — and it is now + identical *by construction* rather than by two files agreeing on which + operations count. +- c6c895c: **Perf:** the declared-capability boot seed and the environment permission-set overlay reconciler each pay ONE batched existence read instead of one per item, and stop re-writing rows that already match (#11096, #11097). + + Both were read-then-write reconcilers over a set known in full before their loop started, and both had the shape #10946 removed from the permission-set and position seeders next door: + + - `bootstrapDeclaredCapabilities` issued a `SELECT … WHERE name = ? LIMIT 1` per declared capability, then an `UPDATE` on its own row whether or not anything had changed; + - `reconcilePermissionSetProjection` projected every environment-scope `permission` overlay in a per-name loop, each iteration issuing its own existence `SELECT` inside `upsertEnvPermissionSet` plus an unconditional `UPDATE`. + + On a local file database these loops are invisible. On the remote libsql/Turso database every hosted environment runs, each leg is its own sequential HTTP request, and the capability set is typically the largest of the identity axes — it is the union of every capability every declared package contributes, not a count bounded by the number of permission sets. + + Both now hoist one chunked `{ name: { $in: [...] } }` read out of the loop through `buildExistingByName`, which keeps the tri-state judgement that makes hoisting safe: **a read that could not ANSWER is not the answer "none of them exist"**. A batched read fails for the whole set at once, so collapsing those two would make a boot during a brief outage try to re-create everything; the seeders now decline the names they could not read, and say so. + + **The write-skip is an equality test, and the reconciliation leg is pinned.** A row whose stored value genuinely differs still gets its `UPDATE` — a reconciler that skipped writes outright would show a perfect round-trip count while silently reconciling nothing, so every counting test added here is paired one-for-one with a drift test over the same fixture, and both pairs were ablated to confirm the drift half fails when the write is removed. + + Two behaviour repairs the write-skip REQUIRED, both on the environment door — not optional polish, but corrections the equality test itself demands, verified by ablation (each one made a specific test fail when reverted): + + - **`customized` is now compared, not just written.** The flag is provenance rather than definition, so `recordDiffersFromBody` deliberately does not compare it; skipping on the facets alone would have stopped maintaining a flag the Setup list badges on and the reset action reads. It gets its own comparison term, against the same `managed_by:'package'` condition the write uses. + - **A newly created environment-authored record is no longer born badged "customized".** The INSERT used to stamp the caller's raw overlay opinion (`!!customized`) while the UPDATE branch's rule stamps `false` for a non-package row — those two disagree for any fresh `managed_by:'admin'` row created while its overlay is still active. Before this changeset, that disagreement was invisible: every boot re-wrote every record unconditionally, so the very next reconciliation pass silently overwrote the wrong value back to `false`. Once writes are equality-gated, that disagreement stops being invisible and becomes a REAL, PERMANENT one-boot-late corrective `UPDATE` after every such creation — the "steady state" round-trip count is not actually flat without this fix. Confirmed on this branch: reverting it to `!!customized` fails `#11097 — env overlay reconciliation: round trips > does not grow the steady-state round-trip count` and `#11097 — drift STILL reconciles > only the DRIFTED overlay is written` (both start seeing a real `UPDATE` on the boot immediately after any overlay-backed admin row is created). + + `projectPermissionMutation` also syncs the in-memory evaluator registry on an unchanged record, not only on a write. That sync is not a database round trip, and the evaluator resolves permission sets registry-first — gating it on "a write happened" would have left a steady-state boot enforcing the stale declared body while the record and Setup showed the overlay. + + ⚠️ **This is a behaviour change beyond the write COUNT**, flagged explicitly: today, a brand-new environment-authored permission set with no package baseline can be observed `customized: true` for the one boot between its creation and the next reconciliation pass (or, on the live write-through door, self-heals within the same request). After this changeset it is never observed `true`. The change is required for the round-trip fix's own steady-state claim to hold on this path — the two are not separable — but it is a resulting-STATE change, not merely a write-count change, and is called out here for that reason. + + ⚠️ **No curve number is claimed for either axis.** The hosted `bootstrap-curve.mjs` rig lives in `objectstack-ai/cloud` and neither of these axes has ever been measured on it. What is established is that the code shape is the one measured at slope 4.0000 / R² = 1.000000 on the two sibling loops in #10946, and that the round-trip COUNT is now flat in the number of declared items — which is what the new tests assert, in counts, never in wall time. +- c33f185: Seed the curated platform capabilities with ONE batched existence read, and stop + rewriting rows that already match + + `bootstrapSystemCapabilities` built its whole definition set in memory and then + issued a separate `SELECT … WHERE name = ? LIMIT 1` per definition, followed by + an `UPDATE` that fired whether or not `label`/`description` had changed. On a + local file database that loop is invisible; on the remote libsql/Turso database + every hosted environment runs, each leg is its own sequential HTTP request, + competing for the same boot request budget as everything else. On a stock + installation that is 8 reads plus 8 writes, every `kernel:ready`, to store bytes + already there. + + The curated half's existence read is now one batched `$in`, and the reconcile is + equality-gated. On a steady-state rebuild the curated half costs **1 round trip** + instead of 16, and the write gate sits after the derived-ownership guard, so it + removes the redundant `UPDATE` from **both** halves. + + **The #8470 predicate travels inside the batched query, not applied to its + answer.** The curated half does not ask "is there a row with this name" — it asks + for the platform's own organization-less row (`managed_by: 'platform'` + + `organization_id: null`), and since `sys_capability.name` became unique per + ORGANIZATION those are different questions. Batching the wide question and + filtering afterwards reads every organization's row for every curated name — a + set bounded only by the number of organizations — against a page capped at one + row per name, so the page truncates, and a truncated page reads as "absent", + which inserts. Both harms are pinned as tests rather than argued: without the + predicate the shared name resolves to an organization's row, and two curated + names whose platform rows demonstrably exist come back absent. + + **An unreadable database now declines instead of guessing.** Hoisting a read out + of a loop changes what a failure means: per item a failed read fell through to an + insert the unique index refused, for that one name; batched, one failure speaks + for the whole set. `unknown` is therefore never read as "absent" — the affected + definitions are left entirely alone, counted in the new `unreadable`, and warned + once. This also retires a misdiagnosis: an unreadable database used to make this + half attempt an insert per curated name and then report a `blockedCurated` + collision for each, describing a blocking row nobody ever saw. + + `CapabilitySeedResult` gains `unchanged` and `unreadable`. Reporting "wrote + nothing because nothing differed" separately from "wrote nothing because the + writes stopped working" is what keeps the round-trip count from being satisfiable + by an implementation that simply stopped reconciling. + + **The derived half keeps its per-item read**, and not because it is the smaller + one — it is the half that grows. Its lookup is cross-organization by + construction, and `skippedAuthored` and the `platformStampedInOrg` anomaly signal + are computed from the lowest-id row installation-wide; narrowing it to the + platform bucket answers a different question and would silently reverse part of a + maintainer ruling, while batching it unnarrowed needs an unbounded read. Filed + rather than taken. + + No speedup is claimed. The hosted boot-curve rig lives in another repository and + its axes are permission sets / positions / objects, not this one. What is + established here is the round-trip count and the identity of the row each leg + reads and writes, both pinned in-repo. +- 5cb62d8: Make `clone_permission_set` carry the system permissions, row-level security + and tab permissions it was silently dropping + + The Clone action POSTs its `params` values to `/api/v1/data/sys_permission_set`, + so the params list *is* the payload. It named two of the six definition facets a + `sys_permission_set` row carries — `object_permissions` and `field_permissions` + — leaving `system_permissions`, `row_level_security` and `tab_permissions` + absent from the body. `permissionSetBodyFromRow()` then read each one through + `parseMaybeJson(undefined, …)` and filled the empty default, so cloning a set + that grants `setup.access`, or one carrying row-level security policies, + produced a clone with none of them: record created, success toast fired, and the + missing half discoverable only by diffing the two records. + + The three now travel, in the same JSON-string shape the two listed columns + already used. Nothing about what the door ACCEPTS changed — `permissionSetBodyFromRow()` + already read all six columns; what changed is what the action SENDS. + + This became urgent one commit ago. The save door now refuses an in-place edit of + a package-declared permission set **and its refusal message tells the admin to + clone**, which made this action the platform's own recommended remedy while it + was still dropping three facets — an admin following that instruction lost + grants quietly. The failure direction was fail-closed (fewer grants), which is + why it was quiet. + + `admin_scope` is **deliberately not copied** (maintainer ruling 2026-08-24). + Putting an ADR-0090 D12 delegated-admin authority onto a brand-new + organization-owned set on the admin's behalf is a privilege decision, not a + field copy. The Clone dialog now says so in its description, so the omission + reads to the admin as a decision rather than as the same silent drop — grant a + scope deliberately on the new set if it needs one. + + Pinned by `packaged-permission-set-lock.test.ts` pin 6, which assembles the + clone payload by READING the action's params list rather than restating it, and + asserts each facet by identity against a non-empty value — the empty default + (`[]` / `{}`) is exactly what a "present" assertion would have accepted. Its + control proves the exclusion is live: the base fixture carries a real + `admin_scope`, and the clone still has none. +- 1a68552: perf(security): batch the derived half of `bootstrapSystemCapabilities`, unnarrowed (#11520) + + `bootstrapSystemCapabilities` reconciles two halves. #11451 batched the CURATED + half into one `$in` read carrying the #8470 predicate and left the DERIVED + half — the union of every `systemPermissions` string that nothing declares — + reading one row at a time, so a rebuild cost `1 + derived` round trips. + + That residue was filed rather than fixed for a reason that has since expired. + Two objections stood: narrowing the derived read to the platform bucket answers + a different question and reverses ruled ground, and batching it *unnarrowed* + needed an unbounded read. #11518 removed the second one — `readNamePage` now + asks for one row more than its page budget and reports the overflow as + `truncated` = "could not answer", degrading loudly to the per-item read — so + the wide batched read became bounded without becoming a different question. + + The derived half now consults its own `buildExistingByName` index, built with + **no predicate**: the read emits `{ name: { $in: … } }` under `seedCtx()` + (`{ isSystem: true }`, the same context the per-item read used), and unscoped + `resolveOwnOrganizationRow` returns the FIRST row with no bucket filter — so + the index resolves to the same lowest-`id` row installation-wide that + `tryFind(…, 1)[0]` returned under #4363's `ORDER BY id ASC`. A steady-state + rebuild costs 2 reads at every derived size instead of `1 + derived`. + + ⛔ The first objection still stands and is now pinned rather than only + documented: the derived read is **not** narrowed to `organization_id: null`. + Doing so would silence #8751's `platformStampedInOrg` anomaly signal in exactly + the case its doc says it is counted for, and would seed the platform bucket in + the case #8552 ruled must be left alone. A new test asserts the derived read's + key set is `name` and nothing else. + + One behaviour change, in the direction #10946 chose deliberately for the + curated half: a derived name whose existence read **cannot answer** is now + DECLINED (counted in `unreadable`) instead of being read as absent. The old + `tryFind` swallowed a failed read into `[]`, which routed the name to its + insert branch — a duplicate placeholder wherever the read failed but the write + did not, refused only where the unique index happens to exist, and silent + either way because the `blockedCurated` diagnostic is curated-only. The + `unreadable` counter and its summary warning now cover both halves; the warning + reports the whole definition set as its total rather than the curated count. +- c61ad20: fix(plugin-security): fail CLOSED on a non-object row in the platform-admin promotion predicate (#12515) + + `bootstrapPlatformAdmin`'s local `isHumanUser` decided "is this `sys_user` row a + HUMAN?" with a bare truthiness check followed by two property comparisons: + + ```ts + const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; + ``` + + On a truthy NON-object input (`'usr_alice'`, a number, `true`) both comparisons + read `undefined` and therefore both pass, so the input scored **human**. The + same question's consolidated owner — `isHumanUserRow` in `@objectstack/plugin-auth` + — requires `typeof row === 'object'` and answers **non-human** for those inputs. + Two owners of one question, disagreeing, and the disagreement fell the wrong way + on the security-critical side: this is the copy that performs the + **platform-admin promotion**, so it failed OPEN. Its worst shape is the system + account's own id arriving as a bare string, which the old spelling would have + promoted. + + The predicate now mirrors `isHumanUserRow` — the same `typeof` guard, and a real + boolean return instead of echoing a falsy input back: + + ```ts + const isHumanUser = (u: any) => + !!u && typeof u === 'object' && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; + ``` + + **Why mirroring rather than a stricter rule of its own.** Over-tightening this + predicate has a worse failure mode than the bug: an install that cannot promote + its first admin is locked out of itself. The guard was therefore measured before + it was chosen, not after. Against a real `SqlDriver` over the shipped `SysUser` + declaration, every row a real `sys_user` read yields is a plain object — zero + truthy non-objects, and zero rows whose verdict moves when the guard is added. + The mirrored guard is also already the incumbent on this exact population: + `plugin-auth`'s dev-admin seed filters the byte-identical read (`sys_user`, + `where: {}`, `limit: 50`, system context) through `isHumanUserRow` today. + + **No reachable behaviour changes.** The divergence is unreachable through any + live call site, so this ships as a hardening of malformed-input handling rather + than a behavioural fix. The 14 existing agreement cases in the cross-package + pin are byte-for-byte unmoved; the pin gains the non-object class it previously + had to exclude (it would have failed), which is what now stops the asymmetry + returning — consolidating the two copies into a shared package stays declined, + so nothing else was going to retire it. +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- 52954c0: `IObjectQLEngine.getSchema` now returns `ServiceObject | undefined` instead of `unknown` (#12481) — the #11833 ruling's fork 3 as executed by #12248, applied one member over by inheritance: `ObjectQL.getObject` is literally `getSchema`'s alias (`return this.getSchema(name)`), the class has always answered `ServiceObject | undefined`, and `ServiceObject` lives in spec (`data/object.zod.ts`), so the contract's "engine-local type" rationale for `unknown` no longer applied here either. FROM `getSchema(objectName: string): unknown` TO `getSchema(objectName: string): ServiceObject | undefined` (authored state, ADR-0122, matching `getObject`). Consumers reading `managedBy` / `fields` / `userActions` off the answer no longer need a cast or a private structural re-declaration; `plugin-security`'s engine-owned write guard drops its now-redundant `as EngineOwnedSchemaLike | undefined` narrowing (behaviour unchanged). Implementations conforming to the class's actual behaviour are unaffected; a fake answering a non-conforming shape now fails compile at the member instead of drifting silently. +- 8af88dd: feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, 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). + Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's + recommendation B; **the keys return with the M2 lifecycle initiative** (feature + + RBAC in one batch) — anchor card #1883 stays open. + + `allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and + `purge` (hard-delete / GDPR erase) ObjectQL operations that have never + existed: no destructive lifecycle verb is in the engine's dispatch vocabulary + (pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). + Authoring the bits granted nothing — and in the `allowPurge: false` direction + the failure was ADR-0049's worst false-compliance shape: an admin believed a + lock on permanent deletion existed when the operation itself did not. The + sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` + door) and is untouched. + + **What is refused:** authoring either key, with any value — both are + `retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the + `permission` metadata root, so the tombstone route keeps the removal audible: + a tsc `never` on the input type plus a parse-time prescription). The former + `restore` / `purge` bare-verb aliases now answer with the same prescription + instead of a rename onto a tombstone. The tombstone rides the `.extend()` + clone into `EffectiveObjectPermissionSchema`, so the response-side def carries + the same `[RETIRED]` rows. + + **What stays accepted:** every other object-permission bit parses + byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, + `viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). + + **Runtime (plugin-security):** the evaluator's pre-mapping rows + (`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in + the same batch — with the bits unwritable, a mapping onto them was a claim + about a surface that rejects authoring. Behaviour is deny-before and + deny-after: a dispatched `restore` / `purge` is refused fail-closed by the + `DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even + `modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers + them only when the M2 batch re-adds the rows). `transfer` keeps its row and + its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy + stored value grants nothing, so flagging it guarded nothing real); the + delete/purge/transfer class message is unchanged. + + The retirement kit: + + - `retiredKey()` tombstones + former-alias `guidance` prescriptions at the + schema (`packages/spec/src/security/permission.zod.ts`) + - ADR-0087 registration: retired-key entries + `security/ObjectPermission:allowRestore` / `:allowPurge` (and the + `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 + conversion `permission-allow-restore-purge-removed` (protocol 18), wired + into the step-18 chain — `os migrate meta --from 17` strips the keys from + every object grant in `permissions[].objects` (pure lossless delete; they + never had an effect to lose) + - liveness ledger: both entries flipped to `dead` with the retiredKey evidence + (entries stay — the tombstone keeps the keys in the walked shape, the + `rls.priority` precedent) + - pin tests (`permission.test.ts` — refusal pins asserting the prescription; + `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant + and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate + no longer reads the retired bit) + - generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, spec-changes, upgrade guide, reference docs) + + ## FROM → TO + + ```ts + // before — parsed green; nothing ever read the bits, no operation existed + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { + allowRead: true, allowEdit: true, + allowRestore: true, // claimed: can undelete — nothing enforced it + allowPurge: false, // claimed: GDPR erase locked — no lock existed + }, + }, + }); + + // after — delete the keys; restore/purge dispatches are denied fail-closed + // until the M2 lifecycle batch ships the operations WITH their RBAC bits + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { allowRead: true, allowEdit: true }, + }, + }); + ``` + + +- 31bb2e7: fix(plugin-security): report the two swallowed `tryUpdate` refusals outside the catalog seed (#12970) + + Both sites call the shared `tryUpdate` in `permission-set-projection.ts`, which + answers `false` on refusal. That answer is byte-identical to "nothing to do", + and neither caller passed the optional refusal log the helper already accepts — + so a refused write was indistinguishable from a clean pass. + + **`permission-set-drift.ts` — a refused diagnostic write silenced its own + report.** `persistPermissionSetDriftDiagnostics` counted only the writes that + landed, and `runPermissionSetDriftDiagnostics` reported only when that count was + non-zero. A boot on which every drift write was refused computed the drift + correctly, persisted none of it, and printed nothing at all — indistinguishable + from a deployment with no drift, while the sets kept enforcing grants that + differ from the shipped artifact. The pass now records refusals, answers a + `refused` count beside `updated`, reports them once per pass on the durability + channel, and emits the drifted-set line when writes were refused as well as when + they landed. A steady-state boot (nothing to write, nothing refused) stays + exactly as quiet as before. + + **`permission-set-overlay-discard.ts` — the audit line could describe a discard + that did not happen.** On the degraded-kernel branch the resync write's result + was discarded entirely. On refusal the row was re-read unchanged, so + `objectGrantsAfter` equalled `objectGrantsBefore` while the `info` entry still + announced a completed "sanctioned operator action": every field individually + true, the entry as a whole false. The result is now read, and a refused resync + emits one entry stating what did and did not land — the overlay row deletion + (which had already succeeded) and the refused resync, with the un-healed grant + count named as such — **instead of** the success line, never alongside it. + + Both new lines go through the shared durability channel with its mandatory + `warn` fallback, so they still print against a host sink that has no `error`. + They reuse the shared refusal *accumulator* (`createSeedWriteRefusals`, with its + cross-dialect classification and value-free driver-code channel) but not + `reportSeedWriteRefusals`, whose prose is specific to seeding the RBAC catalog + and would misdiagnose either of these paths. + + No API is removed or narrowed. `persistPermissionSetDriftDiagnostics` and + `runPermissionSetDriftDiagnostics` answer one additional field (`refused`), and + what `discardPermissionSetOverlay` returns to its caller is deliberately + unchanged. +- 502ff8b: An organization-less `sys_permission_set` row grants again — #11121 revoked standing access silently + + #11121 made the request-time permission-set loader tenant-scoped so two + organizations holding a row for the same name stop answering each other's + requests. It shipped the second half as a COMMENT — "an organization-less + leftover only where it does not [have its own]" — and the code read `.own` + alone, which by `resolveOwnOrganizationRow`'s own documented contract is never + a residue once an organization is supplied. + + That helper is written for SEEDERS, where refusing to read a residue as + "already seeded" is the entire point. Enforcement wants the opposite reading: an + organization-less row is still a row the principal was granted, and dropping it + revokes standing access with no signal at the moment of loss — the failure this + catalog's own header, and `resolve-authz-context`'s `sys_position` read, both + name as the thing not to do. + + The asymmetry was observable on a single row: its `system_permissions` and + `tab_permissions` kept applying, because that read is unscoped and by id, while + its `object_permissions` and `admin_scope` stopped. One row, two enforcement + planes, opposite verdicts. Every walled deployment carrying pre-#11121 rows — + or any row authored without a tenant, which includes admin-UI-authored sets — + lost those grants on upgrade, reported only as a boot WARN about "leftovers" + that states the catalog is complete. + + Found by cloud's `apps/ee-group-showcase` dogfood suites, which had been failing + four ADR-0111 / ADR-0105 assertions on cloud main while turbo replayed them from + cache. + + Preference order is unchanged, so the cross-tenant bleed #11121 closed stays + closed: this organization's own row still WINS wherever it exists, and a + leftover is consulted only in its absence. #11121's suite covers seeding and the + `sys_position` sweep; the three cases added here cover the loader path it did + not — residue resolves, own beats residue, and the single-posture carve-out is + untouched. Reverting the one-line fix reddens exactly the first of them. +- d7b3963: Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test. +- f64668d: fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync (#12059) + + Four text columns that a declared index keys on carried no `maxLength`, so + `driver-sql` emitted them `TEXT`. MySQL refuses a TEXT/BLOB column in a key + without a key length (`ER_BLOB_KEY_WITHOUT_LENGTH`): `CREATE TABLE` succeeds, + `ALTER TABLE … ADD INDEX` fails, and the object lands registered-but-broken + with its declared index silently absent. + + | Object | Column | Bound | Producer the bound is derived from | + |---|---|---|---| + | `sys_activity` | `record_id` | 255 | the physical `id` column — `driver-sql` creates every primary key as `table.string('id').primary()`, knex's `varchar(255)` | + | `sys_audit_log` | `record_id` | 255 | same | + | `sys_audience_binding_suggestion` | `package_id` | 255 | `sys_permission_set.package_id` (255), which the same boot pass writes the same value into | + | `sys_audience_binding_suggestion` | `permission_set_name` | 100 | `sys_permission_set.name` (100), the column this value resolves against at confirm time | + + Each bound is derived from a **named producer** and stated in the declaration + so it is vetoable in review (#11374 route A; PR #12058 is the worked + precedent). None of them narrows anything storable: + + - a record id cannot exceed the `varchar(255)` column the id itself lives in, + and the `referenceVia` seed path refuses an unresolvable pointer rather than + storing a natural key verbatim; + - a permission set name longer than 100 is already refused at the write seam + today — measured on a real engine, `ValidationError: API Name must be ≤ 100 + characters (got 101)` — so no set with such a name can exist, and a + suggestion naming one could never be confirmed. + + Measured at the driver level, shipped declaration vs. the same declaration with + the bounds stripped: `record_id`, `package_id` and `permission_set_name` move + `TEXT` → `varchar(255)` / `varchar(100)`, while `id` reads `varchar(255)` in + both — the transitivity premise, read off a real table rather than assumed. + + Existing deployments are not rewritten: a physical `TEXT` column is deliberately + not diffed against `maxLength` (#11431), so no `ALTER` is planned and no value + at rest is truncated. The repair takes effect where the decision is makeable at + all — at `CREATE TABLE` — because no dialect turns a TEXT column into a keyable + one afterwards. + + Each plugin also gains a keyed-text-bounds pin driven through its **own + registration path** (`init()` → the manifest `register({ objects })` call), + rather than a hand-written object list: the platform-objects pin enumerates only + that package's exports, which is exactly why these four columns escaped route + A's sweep after ADR-0029 K2 moved the objects out. +- d48929e: fix(security): a refused RBAC catalog write is now boot-visible instead of reporting a seed of zero (#12923) + + The five RBAC catalog seeders answered a refused write with `null`/`false`, + which is byte-for-byte the answer for "nothing to do": the `seeded` counter + never incremented and the pass returned normally. On a deployment still + enforcing a **platform-wide** unique index on the name column — the shape that + predates per-organization materialization — every per-organization INSERT is + refused that way, so the boot log read as a successful seed of zero rows. + Measured on a deployed plane, undetected for weeks: an empty Setup (no + positions, no permission sets, no capabilities) under a clean log. + + The outer handler was not missing, it was **disarmed**. `security-plugin.ts` + already wrapped the organization-creation seed in a `try`/`catch` that warns, + and it was unreachable for this failure class: the refusal was converted to + `null` three call layers below, so the `await` resolved normally and the hook + logged "RBAC catalog seeded" at `info` over a seed of nothing. Another outer + `try`/`catch` would fix nothing — the signal has to survive the inner helper, + which is where the change is. + + Each seeder now accumulates the writes the database refused and reports them + **once per object per class per pass**, beside its counts: + + - a **unique violation** is named as a deployment-schema defect, with the + migrate remedy (`os migrate plan` → `os migrate apply`, where the legacy + index surfaces as a `replace_unique_index` operation) and a pointer to the + query engine's own redacted `Insert operation failed` entries, which keep the + colliding index identifier; + - anything **else** gets its own line and is never relabelled as the above, + because no migration repairs it. + + Classification uses the shipped cross-dialect predicates in + `@objectstack/types` (`isUniqueViolationError` / `uniqueViolationColumn`), not + a local `23505` / `ER_DUP_ENTRY` regex. The warning prints only the value-free + `code`/`errno` channel — never the driver's message, which a SQL driver + prefixes with the fully bound statement. + + Diagnosis only: the seeders still **warn and continue**, never throw. A rethrow + would turn a silent degradation into a boot failure on every deployment + carrying the legacy index. Counts, accept/reject behaviour and the healthy-path + logs are unchanged, and a pass that refuses nothing stays silent. +- e1d773e: fix(security): stop reading a truncated existence page as "absent" — the unscoped page cap is now measured, not trusted (#11518) + + `buildExistingByName` (`seed-name-lookup.ts`) is the batched existence oracle the + identity seeders consult in place of a per-item read. Its UNSCOPED page was + capped at `limit: names.length`, which is exact only while one row can exist per + name. Since #8461 / ADR-0120 D1 `sys_capability.name` and + `sys_permission_set.name` are unique **per organization**, and ADR-0066 D1 + explicitly encourages admins to EXTEND the registry inside their own + organization — so one name legitimately carries a row per organization plus the + platform's, and an unscoped page of N names can match far more than N rows. + + The rows that fall off a full page are the highest `id`s under #4363's + `ORDER BY id ASC` tie-breaker, so **whole names vanish from the page** — and a + name missing from the page reads as `absent`, which routes its caller to the + **INSERT** branch. #10103 had already found and repaired exactly this on the + SCOPED arm; the unscoped arm never got the repair, and two seeders on `main` + read unscoped (`bootstrapDeclaredCapabilities`, `permission-set-projection`'s + env-overlay pass). + + ⛔ `names.length * 2` would have been the same defect with a larger constant: + rows-per-name is bounded only by the number of organizations, so no constant + multiplier is correct. Instead the cap stopped being a promise and became a + **measurement** — the read asks for one row MORE than it is willing to hold, and + a page that comes back carrying that extra row is a PREFIX of the answer rather + than the answer. It then joins the module's existing "could not answer" causes + and degrades to the per-item read, the fallback already there for a driver + without `$in`. Both directions are exact: no complete page is ever mistaken for + a truncated one, and no truncated page for a complete one. + + **Behaviour change, stated rather than slipped in.** In the truncating case the + two unscoped seeders go from a **silent wrong answer to a loud slow one**: names + that used to be reported `absent` (and re-inserted, or refused by the unique key + as a collision naming a row nobody ever saw) are now answered correctly, at the + cost of one read per name plus a warning naming the object and the budget it + could not fit inside. An install that does not overflow the budget — every stock + one, where a name carries a single row — issues exactly the same single read it + issued before and says nothing. + + The SCOPED arm keeps #10103's cap exactly (`names.length * 2`), because there the + number is a proven bound rather than a budget: `applyTenantScope` returns this + organization's rows plus organization-less ones, and the declared name index is + unique per organization. It gains the same probe, which turns a scoped page that + overflows that bound — reachable only where the unique index is absent or not yet + created — into the same loud degradation instead of a silent truncation. +- c0714eb: Walled platform-admin elevation now requires the owner-email match to be + VERIFIED, and the bootstrap re-runs on the verifying update (#11343) + + Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched + the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on + `sys_user` — with no `email_verified` condition, while email verification is + off by default. #11211 narrowed elevation from "whoever registers first" to + "the declared owner's address" (a real and large narrowing); this closes the + remainder that card #11343 records: in the window before the owner registers, + an account created with the owner's address would still be elevated. + + Two halves, deliberately in one change: + + 1. **The elevation match requires `email_verified`** (fail-closed allow-list + over driver representations; an absent field on an imported/legacy row + reads as unverified). An unverified holder of the owner's address is + refused like any stranger — new reason `walled_owner_not_verified`, logged + loudly with the unblock in the line. Never falls back, same direction as + the undeclared-owner refusal. + 2. **The bootstrap-replay middleware now also fires on `sys_user` updates + touching `email_verified` / `email`** (trigger set extracted as + `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). + Verification is an UPDATE — with the old insert-only replay, requiring + verification would have refused the genuine owner at sign-up and then + never looked again, leaving the platform without any administrator. + + `single` posture is untouched both ways: first-user promotion (ruled + reasonable in #11184) does not gain a verification requirement, and the + owner-email variable is still never consulted there. Both directions are + pinned: the unverified holder is refused AND the verified owner is elevated — + including across the refuse-then-verify-then-re-run sequence. + + The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with + `email_verified` stamped: it is created by the deployment's own boot command + with operator-known credentials — the same trust shape as a trusted-SSO + insert, not an unknown self-registrant — so walled dev/harness boots keep a + promotable declared owner. The generic sign-up path is unchanged. +- 4d5b4f8: feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (#12751) + + On a **walled** deployment (`OS_TENANCY_POSTURE` in the wall-enforcing + family), the account whose email equals the declared platform owner + (`OS_PLATFORM_OWNER_EMAIL`) is stamped `emailVerified` **at creation** when + it comes into existence through an **operator provisioning path** — extending + the #11343 dev-boot seeded-admin precedent to production walled boots + (maintainer ruling 2026-08-28, cloud#1677: 「运营方创建即视为已验证」; the + trust anchor is the operator's env-var declaration plus the + operator-executed creation, not a mailbox round-trip; SMTP stays required + only for inviting others). + + **Which creation paths qualify** (the [#11739] audience taxonomy, not a + second classification): + + - the **bootstrap carve-out** — the very first account on a fresh install + (zero human users), the one self-serve creation a walled boot admits; + - **admin create-user / bulk import** (`method: 'admin'`) — an act only an + authenticated admin session can perform; + - **SCIM** (`method: 'scim'`) — provisioning executed by the + operator-registered directory. + + **Never**: non-bootstrap self-registration (including an + invitation-admitted registration typing the owner address), provider-class + JIT (the IdP asserts its own `emailVerified` at insert), any non-owner + address, any unwalled posture, and a later email **update** to the owner + address (the stamp is staged at the admission gate and consumed once by the + `user.create` before-hook — a seam an update cannot traverse). Dev-boot + behaviour (#11343) is unchanged. + + The `WALLED_OWNER_NO_VERIFICATION_PATH` boot warning now probes the owner + account's state: a fresh walled boot with no transport and no federated + sign-in is **silent** (the operator's own first-account creation arrives + verified — the case this closes), while an owner account that already + exists **unverified**, a populated store whose bootstrap window is spent, + and an unanswerable probe keep warning. A settled deployment whose owner is + verified stops re-warning on every boot. + + `@objectstack/types` gains `isEmailVerifiedUserRow` — the [#11343] + fail-closed verified-representation allow-list, moved from + `plugin-security`'s private copy so the elevation gate and the boot + diagnostic read ONE resolution (`plugin-security` now consumes it; no + behaviour change there). +- e3f056f: Stop the per-organization catalog pass from reporting the platform's own + permission sets as "pre-fix" leftovers with a remedy that recreates them + + On a fresh walled deployment (`OS_TENANCY_POSTURE=isolated`, three + organizations) the boot log warned, once per organization, that *"pre-fix + organization-less `sys_permission_set` rows are still present"* and offered + *"re-initialize the deployment, or adopt each row by hand"*. Both halves were + wrong there: + + - **Nothing was pre-fix.** The eight rows it named (`admin_full_access`, + `organization_admin`, `organization_admin_no_bypass`, `member_default`, + `viewer_readonly`, `mcp_agent_data_read`, `mcp_agent_data_write`, + `mcp_agent_restricted`) were minted 1.3 s earlier — before the deployment's + first organization existed — by `bootstrapPlatformAdmin`, the fifth seeder, + which the #10103 ruling deliberately left outside the per-organization + conversion. An operator on a deployment hours old was told they were carrying + legacy state they never had. + - **Its first remedy did not terminate.** Re-initializing a fresh walled + deployment mints exactly those eight rows again on the next boot, so only the + hand-adoption branch ends — and that one hands a platform-wide bucket to a + single tenant. + + The pass now separates the two classes it was conflating and reports each with + the remedy that fits, carrying a machine-readable `origin` + (`'platform-bucket'` / `'pre-fix-residue'`) beside the named rows: + + - the **platform bucket** — names an organization-less writer still seeds on + every boot — is reported as what it is, states that this organization's own + copies were created and no action is required, and says plainly that + re-initializing does *not* clear it; + - a **genuine pre-fix leftover** keeps the original wording and the original + remedy, unchanged. + + Membership is decided by name rather than by `managed_by`, because the question + the remedy turns on is "will a re-initialized deployment have this row again?" + — true for these names whatever provenance the current row carries (a + pre-#8692 install stores `'admin'` on the very same names). It falls back to the + shipped `defaultPermissionSets`, so a host that never threads the new + `platformBucketNames` option still classifies correctly; the option exists for + a host that overrode `SecurityPluginOptions.defaultPermissionSets`. + + `bootstrapPlatformAdmin` also declares what it wrote: under a walled posture it + now logs that the platform defaults were seeded *without* an organization and + that each organization's copies come from the catalog pass. The rig's boot line + read `{"seeded":8}` with nothing to indicate the rows carried no organization + at all, so the operator's first sight of them was the warning above. + + **No behaviour change to the seeding itself.** The eight rows are still minted, + still organization-less, still unreaped — that is the ruled outcome of #10103 + (2026-08-20), and `PLATFORM_ADMIN` is derived from an unscoped grant pointing at + the `admin_full_access` row *by row id*, so removing them would silently demote + every platform admin. Whether the platform bucket should be materialized per + organization remains the maintainer's open call, not this change. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 480c0d8187..a92530c128 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.2.0", + "version": "17.3.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack \u2014 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 1475556a41..d76484fdec 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,449 @@ # @objectstack/plugin-sharing +## 17.3.0 + +### Minor Changes + +- 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + + The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + + The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. +- 18b53ac: ⚠️ **Published-contract break for external hosts.** The `logger` option on the three + PUBLICLY EXPORTED options types — `SharingServiceOptions`, `ShareLinkServiceOptions` and + `SharingRuleServiceOptions` — now **requires** a `warn` channel, and its members carry real + signatures (`(msg: any, ...rest: any[]) => void`) instead of bare `Function`. A host that + constructs any of these services with `{ logger: { info, error } }` compiles today and stops + compiling after this release; add `warn` (or drop the `logger` option) to migrate. Nothing + about the services' runtime behaviour changes, and no call site inside this package moved: + the tightening was measured at ZERO compile errors within `plugin-sharing`, so the whole cost + falls on hosts, which is why it is declared here rather than shipped as a patch. + + Why: #9754 rules that a sink declaring an optional `error` must declare a NON-optional + `warn`, so a durability report always has somewhere to land — an optional `error` beside an + optional `warn` is a contract that permits silence. These three sinks were red against that + rule from the day it was written and were only invisible to its checker until #11069 taught it + to read bare `Function` as a channel. The maintainer ruled on 2026-08-24 (#10556) that they + tighten rather than stay baselined, shipped `minor` with the break named here. + + The bare-`Function` spelling was also its own defect: `Function` is not assignable to a + concrete signature, so `record-orphan-cleanup.ts` could not tighten its own `MinimalLogger` + while these producers stayed loose (#10692). That producer-side blocker is now clear. +- 3194c91: ⚠️ **BREAKING (published parameter tightened):** `sweepOrphanedRowsByRecordExistence` — + publicly exported from `@objectstack/plugin-sharing` — now types its optional `logger` + parameter as `{ info?, warn }` with real signatures (`(msg: any, ...rest: any[]) => void`) + and a **required** `warn`, replacing the old `{ info?: Function, warn?: Function }`. A host + that passed this function a logger without a `warn` member (for example an + `{ info, error }`-only literal) compiles today and stops compiling after this release. The + one-line fix: add a `warn` callback to that logger object — or omit the `logger` argument + entirely, since the parameter itself stays optional. Nothing about the sweep's runtime + behaviour changes, and no call site in this repo moved: both in-package callers forward the + owning services' `logger` options, whose `warn` is already required since the producer + tightening (`SharingServiceOptions` / `ShareLinkServiceOptions` / + `SharingRuleServiceOptions`) shipped in the previous release. + + Why: every report this sweep emits lands on `warn` — the "could not check whether records + still exist", "stopped early" and "revoked N rows" lines — so a logger without a + guaranteed `warn` is one the sweep can lose its ONLY output into. That is #9754's + permit-silence shape, one module downstream of the producers #10556 tightened, and the + maintainer ruled (#10692, 2026-08-25) that this package's logger contracts refuse it + loudly at compile time rather than keep it. The bare `Function` members were also their + own defect: they documented no call shape and caught no arity mistake. The refusal is + pinned at compile time in `logger-required-warn.pin.ts`, which also pins the three + publicly exported options types above. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + +- 65759ba: fix(plugin-sharing): the by-id write denial renders through the Operation + Message Catalog instead of a hardcoded English sentence (#12260, the consumer + half of the key #12493 landed) + + A user holding object-level allowRead + allowEdit — and no `modifyAllRecords` — + PATCHed a record they do not own on an object declaring + `sharingModel: 'public_read'` with `access: { default: 'private' }`. The sharing + middleware refused, correctly, and the client showed the server's reason + verbatim to the end user: one hardcoded English sentence naming the object's API + name and the row's opaque id. In a fully Chinese deployment that was the only + thing the user was told about why their save failed. + + The refusal now renders through the shared Operation Message Catalog in + `@objectstack/spec/system` under the key `record_write_denied` that #12493 + landed for it — the same mechanism `plugin-security`'s record-level denial + already uses, which is exactly the comparison the report drew: the same "I can + see this record but cannot change it" situation showed human language or raw + English depending on which layer refused. Same resolution ladder (deployment + override → the caller's locale → `en` → the key), same guarantee that a + misbehaving i18n service cannot turn a 403 into a 500. All four platform + locales (`en`, `zh-CN`, `ja-JP`, `es-ES`) ship copy that sends the reader to the + record's owner or an administrator instead of dead-ending them. + + `record_write_denied` is deliberately not `record_access_denied`: this gate + fires on a row the READ path already admitted, so "You do not have access to + this record" would be false the moment it rendered. It is one key for BOTH write + verbs — the user's situation and remedy are identical for `update` and `delete`. + + `buildSharingMiddleware` gains an optional third argument, a lazily resolved + `II18nService.t`-compatible lookup wired by `SharingServicePlugin`, because the + i18n service is contributed by another plugin and may start later. It is what + makes the override address the catalog documents, + `errors.record_write_denied`, take effect for this emitter. The argument is + additive: every existing caller passes two and is unchanged, and a stack with no + i18n service still renders the built-in catalog in the caller's locale. + + **Not changed: who may write.** The gate is byte-identical — ownership, write + depth, an edit-level share for `update`, Modify All Data — and the app-authored + RLS deferral ahead of it is untouched. The `FORBIDDEN:` prefix the REST layer + classifies 403 on is untouched, and so is the ADR-0111 D10 `delete`-verb + diagnostic breadcrumb. The verb, object and row id the old sentence carried are + now developer facts on the error's `developerMessage` and `details` and in the + log, where a developer reads them and a user never does. + +### Patch Changes + +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- 9e72090: fix(sharing): honour the declared `organizationId` in `managerOf` (#10231) + + `ITeamGraphService.managerOf(userId, organizationId?)` declares an organization + parameter. `TeamGraphService.managerOf` spelled it `_organizationId` and + discarded it, and the `BusinessUnitGraphService` standalone fallback read + `sys_user` the same unscreened way — a declared-but-unenforced parameter on a + security seam, while `expandRoleUsers` on the same class applied + `organization_id` to its own read. + + Both now apply the screen #10153 landed for the identical column + (`sys_user.manager_id`) on the approvals side: a manager who is **provably** + outside the caller's organization — membership rows exist for him, none of + them in that organization — is dropped. The read is `sys_member`, because + `sys_user` is the global better-auth identity table and carries no + `organization_id` at all; filtering the `sys_user` read on a column that does + not exist would match nothing and silently return `null` for every lookup. + + The screen is fail-open on an ABSENT tenancy fact (no membership rows, or the + membership read failed) and issues no query at all when no organization is in + play, so callers that pass nothing — which is how the parameter is used today — + are byte-identical to before. The manager cache key is now organization- + qualified; a user-keyed cache would have served one screened `null` to every + unscoped reader behind it. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 8fd0d866f9..9411a3a5a4 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.2.0", + "version": "17.3.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 25e8031234..592dad62b0 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,328 @@ # @objectstack/plugin-webhooks +## 17.3.0 + +### Patch Changes + +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- 3dafd8c: Fixed the `sys_webhook` object's `ja-JP` display name (`label`) to ウェブフック, matching the already-Japanese `pluralLabel` and the bundle's own help prose (`object_name.help`, `triggers.help`), which both use the same term. Previously `label` was a stale `Webhook` fill left over from a prior source revision, so the object rendered its own name two different ways within the same locale in the Setup/admin UI. `description` (which uses "Webhook" inside a longer Japanese sentence) and the `Webhook ID` field label are unchanged — this only renames the object itself. `zh-CN` is deliberately untouched: it uses `Webhook` for both slots on purpose, as there is no established Chinese rendering of the term in this codebase. +- 598b7ec: fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) + + `os i18n extract` merges gaps only, so a revised source string leaves the previous + revision standing in every translated locale — in sync by key, green under + `check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves + `check:i18n-stale-fill` froze in its baseline are re-translated here from the + **current** `en` source, and the baseline is ratcheted to empty in the same change. + + User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: + + - `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a + `"certified"` governance flag that was removed from the declaration in 16.0. + - `sys_webhook.fields.method.help` — all three locales served the pre-revision method + enumeration after the source became a prose description. + - `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now + Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose + uses and which carries no plural inflection. + - `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and + `zh-CN` a translation of the same superseded source; all three now carry the + PARKED / terminal-row clause the source documents. + - `sys_notification_subscription.fields.principal.help` — the selector list was missing + the `owner_of:object:id` and bare-email forms in all three locales. + + No schema, export or runtime behaviour changes: translated-locale leaf values only, + plus the shrink-only ratchet baseline. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [30928a6] +- Updated dependencies [de47336] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [b6d9432] +- Updated dependencies [3b5f036] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e4902d2] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [d9cf78e] +- Updated dependencies [1a47a53] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/service-messaging@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index af4f1ba1a1..f246043b69 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.2.0", + "version": "17.3.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 d253871141..de51c8e374 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,293 @@ # @objectstack/dogfood +## 0.0.43 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [c8be110] +- Updated dependencies [74cee59] +- Updated dependencies [399ecad] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [32448d4] +- Updated dependencies [54e2d36] +- Updated dependencies [7ef0268] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [4bd6faa] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [56d3c7a] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [09b4f4e] +- Updated dependencies [30928a6] +- Updated dependencies [de47336] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [3dafd8c] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9e72090] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [4ceae8a] +- Updated dependencies [48318f7] +- Updated dependencies [b6d9432] +- Updated dependencies [3b5f036] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e4902d2] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [017130a] +- Updated dependencies [52954c0] +- Updated dependencies [466b389] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [d9cf78e] +- Updated dependencies [1a47a53] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8649b39] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [31bb2e7] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [db39dfc] +- Updated dependencies [3c0f3ea] +- Updated dependencies [26deb31] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [599515d] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [d48929e] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [f4e741b] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [911da5f] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a3c4215] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [18b53ac] +- Updated dependencies [3194c91] +- Updated dependencies [65759ba] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [47d9b77] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [a41069b] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [91b1342] +- Updated dependencies [88b9d74] +- Updated dependencies [c94be62] +- Updated dependencies [96940b7] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [2ff01cf] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [4d5b4f8] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/service-analytics@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/verify@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/service-messaging@17.3.0 + - @objectstack/service-storage@17.3.0 + - @objectstack/plugin-audit@17.3.0 + - @objectstack/plugin-sharing@17.3.0 + - @objectstack/plugin-webhooks@17.3.0 + - @objectstack/mcp@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-email@17.3.0 + - @objectstack/example-crm@4.0.95 + - @objectstack/example-showcase@0.3.17 + - @objectstack/connector-mcp@17.3.0 + - @objectstack/connector-openapi@17.3.0 + - @objectstack/connector-rest@17.3.0 + ## 0.0.42 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 0a7b5db2b4..9034b11d87 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.42", + "version": "0.0.43", "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 d2bdead110..40defde91b 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/downstream-contract +## 0.0.41 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + ## 0.0.40 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 190948fb3e..7ab3c3dc79 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.40", + "version": "0.0.41", "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 a23cddd4e8..c429a490ba 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,25 @@ # @objectstack/http-conformance +## 0.1.3 + +### Patch Changes + +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [f658793] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [2d5cee3] +- Updated dependencies [af56546] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [7131f12] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [49f0dcf] +- Updated dependencies [f7b25c5] + - @objectstack/core@17.3.0 + ## 0.1.2 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 7a4eb6cfe6..6646106b27 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.2", + "version": "0.1.3", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index b02780c2ab..ba910dd6f0 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,1345 @@ # @objectstack/rest +## 17.3.0 + +### Minor Changes + +- 25b1b81: Surface "declared ≠ enforced" on package-declared permission sets, and give + operators a sanctioned, audited way to discard a stale environment overlay. + + Field report: an rc→GA upgraded environment can freeze a package's + permission set at a stale snapshot while the shipped artifact keeps + shipping grant changes — silently, with only a boot log counter as a + signal. Two independent mechanisms can cause this, and either (or both + together) can be live on one row: + + - **overlay shadow** — a Studio permission-matrix save on a package-declared + set materializes a `sys_metadata` overlay that shadows every later package + edit to that set, forever, surviving redeploys and restarts; + - **provenance skip** — a `sys_permission_set` row whose `managed_by` column + predates package provenance tracking is treated as environment-authored + and never reconciled with the package. + + `sys_permission_set` now carries `drift_status` / `drift_detail`, recomputed + every boot, naming the set and the cause — a new "Needs Attention" Setup + list view surfaces only sets that actually differ from their shipped + artifact (an in-sync set is never flagged; `drift_status` stays `null`). + + A new "Discard Overlay" Setup action (`POST + /api/v1/security/permission-sets/:id/discard-overlay`) removes a stale + overlay and resyncs the record to the current artifact synchronously — the + supported, audited counterpart to the raw-SQL remediation the field report + had to use. It targets package-declared sets only: a set with no current + package declaration is refused, so a genuinely environment-authored set can + never be discarded by name collision. + + Boot-time auto-adoption of legacy rows and a bulk `os meta + adopt-permission-sets` command remain out of scope (2026-08-20 maintainer + ruling) — the manual SQL adoption recipe stays documented for the rc→GA + provenance-skip case; see the ops runbook. +- d2b2381: **Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). + + `saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. + + The maintainer ruled a **split**, and the two halves are not the same fix: + + - **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. + - **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. + + For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. + + The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. +- d81838c: `GET {basePath}/openapi.json` no longer overwrites `info.version` — the served + document publishes the version `packages/spec` put in the artifact + + **FROM** the deployment's declared API version identifier (`api.version`, which + `normalizeConfig` defaults to `'v1'`) — **TO** the published artifact's own + version (`@objectstack/spec`'s `./openapi.json` export, `17.2.0` at the time of + this change, set from that package's version by `build-openapi.ts`). + + The `info` block is the half of the document `packages/spec` produces and owns, + and this route's own test twin has asserted that "serve-time enrichment must not + touch it" since #5588 — with the assertion narrowed to `info.title` alone, + precisely because `version` was overridden. The invariant was stated and then + excepted, in the same file. The override is deleted, the exception is gone, and + the twin's assertion now covers the whole `info` block. + + Nothing is lost. The declared API version identifier still exists and is still + observable where a caller can act on it: it builds the mount + (`${basePath}/${version}` gives `/api/v1`). The runtime version is still + answered by `{basePath}/discovery` and `/health`. OpenAPI 3.1 defines this field + as "the version of the OpenAPI document (which is distinct from the OpenAPI + Specification version or the API implementation version)" — the document being + served is the artifact, so its version is the artifact's. + + **Measured consumer pull: zero.** No consumer reads this document's + `info.version` by value, and nothing derives a route prefix from it or compares + it against `api.version` — the repo's one route-prefix derivation + (`packages/core/src/qa/http-adapter.ts`) reads the config directly and is + unaffected. That sweep covers `objectstack` only: `objectui`, `cloud` and + `cloud-v1` were not reachable where it ran, so the zero across those three is an + earlier reading carried forward, not re-measured here. + + The one deployment shape that changes is one scraping the served `info.version` + to learn its own `api.version`; it should read the mount, or + `{basePath}/discovery`, both of which state that fact on purpose. + + This supersedes the `info.version` half of the unreleased + `openapi-info-version-is-the-api-version` entry in the same cycle: that entry's + fallback removal stands, its statement that the served field carries the API + version identifier does not. +- 15eb2c9: feat(security,meta): org-scoped presentation authoring capability `manage_org_presentation` (#12702) + + A tenant org admin in a walled posture can now be granted org-scoped authoring + of exactly the org-overridable presentation types (ADR-0005 tier A: view / + dashboard / report / translation / email_template today — the registry is the + authority) without holding platform-wide `manage_metadata` (maintainer + direction 2026-08-27, quoted in #12701). + + - **spec**: new curated `PLATFORM_CAPABILITIES` entry `manage_org_presentation` + (`scope: 'org'`), seeded into `sys_capability` at boot like its siblings. + Granted by NO shipped permission set — the SaaS operator grants it per + deployment, so existing postures (`single` included) are byte-unchanged by + its existence. + - **metadata-core**: new `metaWriteCapabilityVerdict` — the capability half of + the `/meta` write decision, beside the existing org-scope half + (`organizationIdForMetaWrite`). It admits `isSystem` and `manage_metadata` + exactly as before, and `manage_org_presentation` ONLY when the target type's + registry entry declares `allowOrgOverride: true` (registry-derived via + `declaresOrgOverride`, never a hand-written list) AND the session has an + active organization — the very organization the doors thread, so an admitted + write can only land org-scoped in the caller's own partition: never tier-B, + never env-wide, never another org's. + - **rest**: the four `/meta` item write doors (`PUT` save, `DELETE` reset, + `POST /publish`, `POST /rollback`) run the shared verdict. + `POST /meta/_migrate-stored` stays `manage_metadata`-only — an install-wide + rewrite is env-wide by definition. + - **runtime**: the dispatcher `/meta` `PUT` door runs the same shared verdict + (its `_migrate-stored` twin likewise stays `manage_metadata`-only). + + Refusals keep their transports' existing envelopes (REST `403 FORBIDDEN`, + dispatcher `403 PERMISSION_DENIED`); the tier-B refusal sentence is + byte-identical to before, and the tier-A sentences name the sanctioned path + without disclosing the caller's own grants (#7450). Platform `manage_metadata` + behaviour is unchanged on every door. +- 3519f8d: fix(client,rest): state `SaveReportInput`'s requirements at the `reports.save` door (#11926) + + **BREAKING** accept-set narrowing on `POST /api/v1/reports` and on the + `client.reports.save` parameter type, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + `IReportService.saveReport` takes a `SaveReportInput`, on which `name`, `object` + and `query` are all required. Nothing on the path said so. The SDK method + declared its parameter `any`, and the route forwarded `req.body ?? {}` straight + through, so the requirement held only as far as each reports implementation + chose to re-derive it privately — the bundled `@objectstack/plugin-reports` does + re-derive all three, but a third-party implementation need not, and a caller + could not tell which one it was talking to. This is the ADR-0078 + declared-but-unenforced shape arriving at an authoring surface: the producer + accepted off-spec input and handed it to a service that requires more. + + Both halves now state the contract: + + - **`client.reports.save(report)`** takes `SaveReportInput` instead of `any`. + Omitting `query` (or `name`, or `object`) is now a compile error at the call + site rather than a surprise from whichever implementation is mounted. The SDK + remains a transport and adds no runtime validation — it is not a second + validator. + - **`POST /api/v1/reports`** refuses a body missing any of the three required + keys, and a `query` that is not a `ReportQuery` envelope (a scalar or an + array), with `400` / `VALIDATION_FAILED` — the same envelope the route + already produced for a service-raised validation error (ADR-0112). A + JavaScript or `curl` caller that never sees the TypeScript type is refused + too. The refusal is ordered **after** the existing `501` for an unmounted + reports service: "no reports service on this deployment" is a deployment fact + and outranks anything about the body. An empty `query: {}` stays legal — + every field on `ReportQuery` is optional — and is pinned as such. + + **Migration.** A caller that omitted `query` was already relying on + implementation-specific behaviour; supply the `ReportQuery` envelope the report + should run (`{}` for "no filters"). Callers already sending a complete + definition are unaffected, and the bundled reports implementation already + refused all three omissions, so no deployment running it changes behaviour — + only the layer that produces the refusal moves, from the service to the door. + + +- 1394768: **BREAKING (accept-set tightening)**: `RestServer` now parses `config.api` + against `RestApiConfigSchema` at construction instead of casting to it, so a + deployment whose `api` config the spec rejects fails loudly at boot rather than + booting into a structurally broken URL space (#11637). + + The regex was always declared. `packages/spec/src/api/rest-server.zod.ts` + constrains `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and + `version` is spliced into `getApiBasePath()` — the base of **every** route this + server mounts. Nothing ran it: both hops into `@objectstack/rest` are casts + (`config.api as any` in `rest-api-plugin.ts`, then `as Partial` + in `normalizeConfig`), the plugin declares no `configSchema`, and the kernel's + `PluginConfigValidator` could not have covered it either — `PluginLoader` + invokes its own `validatePluginConfig(metadata)` with **no config argument** and + returns early, and `createRestApiPlugin` closes over its config so the kernel + never receives it. `??` was the only guard left, and `??` substitutes + `null`/`undefined` only. Measured on the pre-fix code: `api.version: ''` + constructed happily and mounted the whole API — `/data`, `/meta`, `/discovery`, + `openapi.json` — under `/api//`. + + **Newly refused, all at `new RestServer(...)` / `createRestApiPlugin().start()`:** + + - `api.version: ''` — the reported case. Refused with + `Invalid string: must match pattern /^[a-zA-Z0-9_\-\.]+$/`. + - `api.version` carrying any character outside `[a-zA-Z0-9_-.]` — `'v1/beta'` + (which spliced an extra path segment into every route), `'v1 beta'`, `'v1%2F'` + and so on. + - A declared key written with the wrong type: `api.enableCrud: 'yes'`, + `api.basePath: 42`, a malformed `api.documentation` / `api.responseFormat`. + + **Deliberately NOT refused** — the narrowing is exactly what the schema + declares, and no more: + + - `api.projectResolution`. The declared enum is + `z.enum(['required', 'optional', 'auto'])`, but the value this platform + actually ships is **`'none'`**: `@objectstack/runtime`'s + `StandaloneStackResult.api` declares the literal type + `{ enableProjectScoping: false; projectResolution: 'none' }`, and `os serve` + forwards it into this config unchanged (`apiConfig.projectResolution ?? 'auto'` + does not fire — `'none'` is not nullish). Three packages disagree about this + key's vocabulary and have done so silently for exactly as long as nothing ran + the schema. Parsing it here would not settle that disagreement, it would turn + every `os serve` boot into a crash, so the key is `.omit()`ed and the + divergence is filed as #11999. Which spelling wins is a contract question + about project-scoping semantics that this seam cannot answer. + - `api.requireAuth`. The retired key (#3963) is `.omit()`ed from the + validation: it keeps the warn-and-ignore posture `rest-api-plugin.ts` gives + it, and `tsc` still refuses it at any typed authoring site. Converting that + warn into a boot failure is #3963's decision to make, not this seam's. + - Keys no schema in `packages/spec` declares, `api.enableSearch` first among + them. The parse is run for its verdict only and its output is **discarded** — + `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()` strips + what it does not declare, and consuming the parsed value would have silently + turned search back on for a deployment that turned it off. + - `api.basePath: ''` — a bare `z.string()` with no declared constraint stays + accepted. + - `crud`, `metadata`, `batch` and `routes`. Those sub-objects are still cast, + not parsed, and carry unenforced constraints of their own + (`batch.maxBatchSize: z.number().int().min(1).max(1000)`, the + `routes.nameTransform` enum). Same defect class, filed separately — this + change deliberately puts one narrowing in front of contract review, not five. + + **Migration.** Delete or correct the offending key; the refusal names the path, + the declared rule that rejected it, and why an empty version is not survivable. + A deployment that meant "no version segment" wants `api.apiPath: '/api'`, which + sets the base outright and is unconstrained. + + **In-repo blast radius, measured repo-wide.** The census is mechanical, not a + reading: 173 files scanned, 316 `api: { … }` blocks brace-matched, every scalar + literal written at each of the 14 declared keys parsed against the schema this + seam runs. **Two values are refused, and both are the deliberate `''` cases in + this change's own pin file.** Every other in-repo literal is accepted, including + all four `projectResolution` spellings in use and the 96 fixtures carrying the + retired `api.requireAuth`. Nested `documentation` / `responseFormat` literals + exist only in `packages/spec`'s own schema tests, which never construct a + server. Of the 237 construction sites repo-wide, exactly one feeds computed + values (`os serve`), traced to the typed literal in `@objectstack/runtime` + above. One in-repo pin had to be retired: `rest-openapi-route.test.ts`'s falsy + `api.version` case, which carried its own written instruction to retire if + normalization ever started rejecting it, replaced here by a pin on the refusal. + + ⚠️ The first version of this census was scoped to `packages/rest` and missed the + five `packages/cli` e2e boots that go through `os serve`; CI caught it. The + radius that matters is every package that CONSTRUCTS a REST server, not the + package the change lives in. + + +- 8965398: **BREAKING (accept-set tightening)**: `RestServer` now parses `api.projectResolution` + against `RestApiConfigSchema` at construction, instead of `.omit()`ing it out of that + parse. + + The exemption existed because `@objectstack/runtime`'s standalone stack shipped + `projectResolution: 'none'` — a value the declared enum has never contained — and + `os serve` forwarded it straight in. Because `RestApiConfigSchema` is a non-strict + object, omitting the key meant the undeclared value arrived, was silently stripped as + an unknown key, and took `'auto'`'s branch by fallthrough, while the discovery handler + copied it verbatim into `discovery.scoping.resolution` — publishing a payload the + platform's own `DiscoverySchema` rejects, on every boot. #11999 (PR #12444) settled the + disagreement by migrating the producer onto the declared `'auto'`; this change withdraws + the exemption it justified. + + **What changes for a caller:** `api.projectResolution` must now be one of the three + values the schema has always declared — `'required'`, `'optional'` or `'auto'`. Anything + else, `'none'` included, is refused at construction with a message naming the key. A + census of every `projectResolution` value in this repo found exactly those three plus + the retired one, and the retired one now survives only inside assertions that it is no + longer emitted — so no in-repo boot path is affected. + + If a config of yours is refused, correct the value at its producer. Do not re-add the key + to the `.omit()`: a strategy outside the enum is wrong where it is written, not where it + is read. + + +- 7986d97: Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + + Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. + Stage 1 declared the item-name grammar and refuses every slash-bearing name at + the publish door, so the routes removed here addressed only names that can no + longer be created. + + **BREAKING — three public REST routes stop answering:** + + | stops answering | use instead | + | :-- | :-- | + | `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | + | `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | + | `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + + Each retired route folded its `:section` and `:name` segments back into one + slash-bearing key (`views/all_leads`) that the protocol layer then treated as a + single opaque string — the section half was never stored, filtered or + enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + + The `@objectstack/runtime` dispatcher stops folding in the same way: its + `/meta` handler requires exactly two path segments for an item and three for + `…/published`, instead of re-joining every trailing segment. A `/meta` path + that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than + falling through to the adapter's anonymous 404. + + **FROM → TO for callers.** Address every item through the single-segment route + and percent-encode the name: + + ``` + GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads + ``` + + `@objectstack/client` now calls `encodeURIComponent` on every `/meta` item + address, so SDK callers need no change: the SDK already sends the new spelling. + Encoding is a **no-op** for every name the item-name grammar admits (lowercase + snake_case segments, optionally dot-qualified), so the bytes on the wire are + unchanged for every name that can be written today. + + A pre-grammar **residue** row whose stored name contains a slash remains + readable, writable and deletable: `%2F` matches the single-segment pattern and + the parameter is decoded back to the stored spelling before the handler runs. + Nothing that could be stored has become unaddressable. + + Two SDK doc comments that promised "compound names pass through unencoded" + (`meta.getPublished`, `meta.publishItem`) are corrected, and the + `SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored + at the compound door and published live — is closed at the source: there is one + door, and it reads every member of the options bag. + + + +### Patch Changes + +- 6747718: fix(rest): `POST /analytics/dataset/query` relays a producer-declared 5xx instead of collapsing it to `500 ANALYTICS_QUERY_FAILED` (#11718) + + **Response-contract change on a shipped public route.** The door is + `POST /api/v1/analytics/dataset/query` (and its environment-scoped twin). An + error whose producer declared a 5xx `status` now reaches the client with **that + status and that ADR-0112 `code`**, where the route previously answered a + hand-built `500` `ANALYTICS_QUERY_FAILED` for every one of them. + + Measured door-to-door before the fix, one error object, both handlers driven in + process: + + | face | answer | + |---|---| + | `POST /api/v1/data/:object` | `503` — `{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}` | + | `POST /api/v1/analytics/dataset/query` | `500` — `{"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` | + + `/data` is the reference and does not move. Its relay is #5582's ruling — `502` + and `503` are `isExpectedDataStatus` lifecycle outcomes that proxies and retry + policies read differently from a `500`, so collapsing them destroys the + declaration — and that ruling never reached this route because the analytics + catch built its 5xx envelope by hand. The **sibling** analytics face + `/analytics/query` already relayed both halves through + `dispatcher-plugin.errorResponseBase`, so this door was the only one of three + overwriting a producer's declaration. + + The repair imports `/data`'s own arm rather than restating it: the branch is + lifted into `declaredServerFaultAnswer` in `error-response.ts` and read by both + doors, the same way the 4xx arm already imports `classifiedRefusalAnswer`. A + third local opinion at this boundary is how the two faces came to disagree. + + **Not a re-opening of #5352/#5367/#5811 — the prose is still withheld.** A + declared server fault's message is still replaced by the generic sentence, from + the same shared arm, and the full original text still reaches the operator: the + `logError` line runs *before* the relay branch and is unconditional, so a + producer cannot buy its way past the operator's log with a declared status. What + moves is the classification the producer declared and this route was + overwriting. + + **What callers see change:** + + - A declared `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` → `503` + `SERVICE_UNAVAILABLE` (was `500` `ANALYTICS_QUERY_FAILED`). + - An unregistered declared code demotes exactly as `/data` demotes it — `503` + `{"code":"SERVICE_UNAVAILABLE","declaredCode":"WAREHOUSE_UNAVAILABLE"}` (#9232). + - `read-scope-sql`'s ten fail-closed RLS refusals answer `500` + `READ_SCOPE_COMPILE_FAILED` instead of `500` `ANALYTICS_QUERY_FAILED`. Their + 2026-08-06 ruling is untouched in substance — still a SERVER fault, still + `500`, still with the RLS policy content withheld from the body and intact in + the log — and the code they now carry is the one they declare and the one the + sibling `/analytics/query` face has always shipped to clients. + + **Unchanged:** an *undeclared* fault. No declared status means nothing to relay, + so it keeps `500` `ANALYTICS_QUERY_FAILED` and #5667's tiering, which leaves a + self-authored fault readable. A declared **4xx** is untouched — that band is + arms ① and ①b, and the half-envelope rule (a 4xx status with no code invents no + code) still stands. +- 340c5e5: fix(rest): `/analytics/dataset/query` carries a producer-marked `userMessage` on its three hand-built terminals (#12710) + + `POST /api/v1/analytics/dataset/query` (and its environment-scoped twin) builds + its error envelopes by hand and shares no exit with the `/data` door, so #9934's + producer-marked `userMessage` — a channel that door applies once at its exit, + branch-agnostically, through `withDeclaredUserMessage` — was applied at none of + them. A producer's caller-facing sentence reached the client on + `POST /data/:object` and vanished here for the identical throw. + + **Scope is by ARM.** Four terminals live in that route's catch; three dropped + the mark and one did not: + + | arm | envelope | before | + | :-- | :--- | :--- | + | ① declared 4xx ADR-0112 passthrough | hand-built `{ code, message }` | ⛔ no mark | + | ①b `classifiedRefusalAnswer` re-dress | `{ ...refusalFields, message }` | ✅ carried it | + | ③a declared 5xx relay | `declaredServerFaultAnswer`'s body, sent verbatim | ⛔ no mark | + | ③b generic `500 ANALYTICS_QUERY_FAILED` | hand-built `{ code, error }` | ⛔ no mark | + + ①b already carried it because its body comes from `resolveErrorResponse`, whose + arms ride the mark already. The other three hold no classification to ride on. + + Measured on `4af6c4419` before the repair, one marked producer per arm, driven + through the real route against the flat `/data` door for the identical throw: + + ```text + throw { code: 'INVALID_FILTER', status: 400, userMessage: 'Check the filter…' } + ① analytics : 400 {"code":"INVALID_FILTER","message":"…"} — no mark + /data door : 400 {"error":"…","code":"INVALID_FILTER", + "userMessage":"Check the filter…"} — mark carried + + throw { code: 'READ_SCOPE_COMPILE_FAILED', status: 500, userMessage: '…' } + ③a analytics: 500 {"error":"Internal server error", + "code":"READ_SCOPE_COMPILE_FAILED"} — no mark + /data door : 500 {…, "userMessage":"…"} — mark carried + + throw Error('[Analytics] no strategy can handle query …') + userMessage + ③b analytics: 500 {"code":"ANALYTICS_QUERY_FAILED","error":"…"} — no mark + /data door : 500 {"code":"INTERNAL_ERROR","userMessage":"…"} — mark carried + ``` + + Nothing invalid shipped — every body parsed as `ApiErrorSchema`, which already + declares the optional field — and that is what made the loss silent and + one-directional: a console told by ADR-0112 to render `userMessage` verbatim + found nothing at these three arms and fell back to its generic substitution, for + the same throw the twin door rendered. + + **What callers see change:** exactly one optional key is ADDED, and only when + the producer marked one. No existing key moves or changes value, at any of the + four arms — pinned as an explicit key-order assertion per arm for an unmarked + producer. + + The value comes from `boundedDeclaredUserMessage` (exported by #12693) — + `declaredUserMessage`'s presence answer with #5423's bound applied — resolved + once for the whole catch rather than at each terminal, so this door has one + answer to "is there a mark, and how long may it be" and shares it with `/data` + rather than copying it. ①b is deliberately untouched: a second application there + would be one rule applied twice. + + **Unchanged:** the prose withhold (#5367/#5437/#5811) — a declared server fault's + message is still replaced by the generic sentence and still reaches the operator + in full through the `logError` line that runs before every arm; the statuses and + `code`s all four arms answer; and #5667's tiering, which leaves a self-authored + undeclared fault readable. + + **Not reachable from in-repo producers today.** Censused at claim: no package + under `packages/services/**` sets a `userMessage` of any kind, and + `service-analytics` dispatches no sandbox hook, so the QuickJS side-channel — the + other in-repo carrier — does not reach this door either. This wires up a declared + channel the published contract already promises on this route's envelope; the + intended producer is an app author's analytics datasource or strategy. +- 7181101: Serve a sandboxed hook's own refusal sentence on the bulk write routes, instead + of the QuickJS debug wrapper + + A hook's `throw new Error('删除被阻断…')` is a deliberate business rule, and + `classifyDataError`'s sandbox unwrap door exists precisely so the end user sees + only that sentence — the ` '' threw: ` prefix "belongs in + server logs", in the door's own words. Six write routes never reached the door. + Measured against the real route handlers: `PATCH /api/v1/data/:object/:id` + answered `Opportunity is closed.` while `POST …/batch`, `…/createMany`, + `…/updateMany`, `…/deleteMany` and `…/:id/clone` answered + `hook 'guard' threw: Error: Opportunity is closed.` — one hook, one refusal, a + different sentence depending on which route the caller happened to use. + + The branch is `resolveErrorResponse`'s declared-status passthrough, which is + checked *before* it delegates to `mapDataError` and answered its 4xx arm from + `error.message`. It now reads the business text through `sandboxBusinessMessage` + — the unwrap door's own two conditions (a non-empty string `.innerMessage`, and + not a `isScriptFaultMessage` crash) named once so the two doors ask the same + question. + + **Not a reorder.** The passthrough's own docblock argues the ordering: handing a + declared 5xx to `mapDataError` re-labels it from the message TEXT (the + overlay-delete fault comes back `404 OBJECT_NOT_FOUND` and stops being logged), + so the arm stays exactly where it is and keeps deciding the status. Only the + sentence it reads changes. #5437/#5582's unconditional 5xx prose withhold is + untouched — a sandbox refusal declaring a 5xx still answers with the generic + text, pinned on both spellings. + + What this restores is an invariant the same docblock already asserts. Its #7525 + paragraph says an error declaring `statusCode` instead falls to `mapDataError`, + "So the two doors already agree on the wire answer." For a sandbox refusal that + was false — `statusCode` was unwrapped and `status` was not — which is the + two-spellings asymmetry this card was filed on. The doors agree again, pinned + door-to-door across the whole 4xx band rather than asserted in a comment. + + **Bump level: `patch`, argued rather than defaulted.** The change is to message + TEXT on shipped routes, so the level is not automatic. It is a patch because + nothing about the envelope's contract moves: same status, same `code`, same + field set, no request newly accepted or refused. The delta is that one string + loses a debug prefix that this boundary already declares must never be on the + wire, and that the single-row routes never emitted — so no client could have + been reading it uniformly in the first place. Keying on the prefix would mean + substring-matching prose that is localised and deliberately reworded over time, + which is the practice the ADR-0112 `code` vocabulary exists to remove. + + `POST /api/v1/analytics/dataset/query` — the seventh row — needed its own + repair: it builds a `{ code, message }` envelope inline and touches neither + door. It now imports the same `sandboxBusinessMessage` rather than re-deriving + the unwrap, so the analytics face and the `/data` face cannot answer one refusal + two ways. Both of its client emissions are covered (the declared-4xx envelope + and the `500 ANALYTICS_QUERY_FAILED` fallback); `logError` still receives the + whole error and `looksLikeInternalErrorLeak` still reads the raw text, so the + operator's copy and the leak heuristic are untouched. + + `POST …/import` and `GET …/export` exit through `handleRouteError` like the bulk + routes, so they are repaired by the same change — measured rather than assumed. + + **Measured and deliberately NOT repaired here**, each recorded so it is not + rediscovered as new: the record-share routes (`…/:id/shares`, list/grant/revoke) + are a third branch again — `respondSharingError` classifies by + `message.startsWith(CODE)` and its fallback interpolates `error.message` into a + hand-built `500`, ignoring a declared `status`/`code` entirely. And on the + analytics route an *undeclared* hook refusal answers `500` where `/data` answers + `400`; only the sentence was corrected, the status disagreement is a separate + defect. Both are filed as their own issues. +- ce744bc: fix(rest): `?mode=draft` now stages on the compound-name metadata write door + + `PUT /api/v1/meta/:type/:section/:name` — the compound-name door, the one you + reach with a name like `views/all_leads` — built its `saveMetaItem` request + field by field and `mode` was not one of the fields. Its single-segment twin + `PUT /api/v1/meta/:type/:name` has read that parameter all along. The parameter + was never refused here, only dropped, so the request was answered `200` and + published **live**. Both doors now read it. + + **Two behaviour changes, and both can be observed by an unchanged caller.** + + **1. `?mode=draft` on this door changes OUTCOME, not acceptance.** The request + was accepted before and is accepted now; what moved is what it does. + + | Request | Before | After | + | --- | --- | --- | + | `PUT /meta/object/crm/task?mode=draft` | `200`, `"state":"active"` — the live row overwritten, nothing staged | `200`, `"state":"draft"` — a staged row written, the live row untouched | + + If you send `?mode=draft` to a compound name today and rely on the write going + live — for instance because you never call `POST /meta/:type/:name/publish` — + those writes stop taking effect immediately and start waiting for a promotion. + Drop the parameter to keep publishing straight away. `mode=publish`, an + unrecognised `mode=`, an empty `mode=` and no `mode` at all are all unchanged: + they publish, exactly as before. The spelling test is the twin's, `draft` + case-insensitive. + + ⚠️ **The draft you can now stage has no per-item REST promotion door in this + arity.** `POST /meta/:type/:name/publish` is mounted for single-segment names + only, while its read twin `GET /meta/:type/:section/:name/published` is mounted + for both — so a compound-named draft is writable and readable over REST and not + promotable there. Until that route exists, promote through + `POST /packages/:id/publish-drafts` (whole-package) or the runtime dispatcher's + own `meta.publish` verb. Tracked in #11932; this release does not change it. + + **2. A repeated `?mode` is now REFUSED where it was accepted.** This narrows + what the door takes. `?mode=draft&mode=draft` arrives as an array; the + `typeof === 'string'` test is false for it, so before this change it fell back + to publishing live under a `200`. It is now answered `400` + `{ "error": { "code": "VALIDATION_ERROR" } }` and nothing is written — the + #6877 guard this door already applied to `force` and `package`, extended to the + parameter it just gained, and the same answer the single-segment twin has given + for a repeated `mode` since #6877. A single occurrence encoded as an array + (`?mode=draft` once) is still accepted; the guard unwraps rather than + blanket-refusing. + + Nothing else on the door moved: `?force`, `?package`, the `meta-envelope` + write face, the `manage_metadata` gate and the `501` envelope are untouched, + and the single-segment twin is untouched. +- 7c0d0c3: fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) + + `api.enableSearch` was a live REST config key with no declared seat: + `RestServer.normalizeConfig` read it through `(api as any)` and honoured it + (`enableSearch: false` really unmounted the search endpoints), but no schema in + `packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, + its own parse **stripped** the key — measured: + `RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an + object with no `enableSearch` property at all — so any consumer of the parsed + config silently got search turned back on for a deployment that turned it off + (the ADR-0104 silent-strip class). It also forced #11637's construction-time + parse to be validation-only, discarding the parsed value. + + - `RestApiConfigSchema` now declares + `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the + runtime's existing default. The opt-out now survives the key's own + contract's parse (pinned), and a TypeScript author can write + `api: { enableSearch: false }` without a cast. + - `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the + newly declared `enableSearch`, the already-declared + `metadata.maskObjectFields` (its declared seat landed separately; the cast + was stale), and the long-declared `enableOpenApi` (stale residue from + before its declaration). `NormalizedRestServerConfig.api.enableSearch` is + now a required boolean like its siblings. + + No runtime behavior changes: defaults are identical (`enableSearch` on, + masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from + cast-reachable to declared = enforced. +- 1f6d047: fix(rest): classify a refusal by what the producer declared, not by how its message starts + + The three record-share routes and `POST /api/v1/analytics/dataset/query` each + built their error answer by hand and shared no branch with the door every + `/data` face reports through, so one refusal got a different wire answer + depending on which route caught it. Both now ask that door first, through a + single new seam (`classifiedRefusalAnswer`), for a refusal the **producer + classified** — a declared 4xx `status`/`statusCode` **plus** a `code`, or a + sandboxed hook body's business `throw`. Everything else is untouched. + + **Per route, old answer → new answer.** Check your error handling if you branch + on any of these. + + `GET /api/v1/data/:object/:id/shares`, `POST` the same path, and + `DELETE /api/v1/data/:object/:id/shares/:shareId`: + + | the thrown refusal | was | is now | + | :--- | :--- | :--- | + | `{ code: 'RECORD_LOCKED', status: 409 }` (any code outside the five prefixes) | `500` `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` | `409` `RECORD_LOCKED` | + | `{ code: 'FORBIDDEN', status: 403 }` — `plugin-sharing`'s own write gate | `500` `SHARE_*_FAILED` | `403` `FORBIDDEN` | + | the same declared as `statusCode` rather than `status` | `500` `SHARE_*_FAILED` | the declared status + code | + | a sandboxed hook refusal, no status declared | `500` `SHARE_*_FAILED`, message = the QuickJS wrapper `hook '' threw: Error: ` | `400` `VALIDATION_ERROR`, message = the hook's own sentence | + | a sandboxed hook body that CRASHED | `500` `SHARE_*_FAILED`, message = the wrapper around `TypeError: …` | `500` `SHARE_*_FAILED`, message = `Internal server error` | + | `VALIDATION_FAILED:` / `PERMISSION_DENIED:` / `NOT_FOUND:` / `CONFLICT:` / `SHARING_NOT_ENABLED:` prefixed messages | 400 / 403 / 404 / 409 / 422 with the prefix stripped | **unchanged** | + | anything else | `500` `SHARE_*_FAILED` with its own message | **unchanged** | + + `POST /api/v1/analytics/dataset/query`: + + | the thrown refusal | was | is now | + | :--- | :--- | :--- | + | a sandboxed hook refusal, no status and no code declared | `500` `{ code: 'ANALYTICS_QUERY_FAILED', error: }` | `400` `{ message: }` — the same status `POST /api/v1/data/:object` answers for the identical throw, and no code, because the producer declared none | + | a declared 4xx + code spelled `statusCode` rather than `status` | `500` `ANALYTICS_QUERY_FAILED` | the declared status + code | + | a declared 4xx + code spelled `status` | the declared status + code | **unchanged** | + | a declared 5xx, a crashed hook body, a driver fault, anything unclassified | `500` `ANALYTICS_QUERY_FAILED` | **unchanged** | + + The nested `{ success: false, error: { code, message } }` envelope the sharing + family answers is unchanged — only the status and code inside it move. The + `VALIDATION_ERROR` on the sandbox row is the catalog's declared floor for a + required `code` the producer did not name (`standardErrorCodeForHttpStatus`); + the flat `/data` body omits `code` there instead, because its `code` is + optional and ADR-0112 invents nothing. +- a02540f: `GET {basePath}/openapi.json` no longer falls back to the spec package's + compile-time version when `api.version` is configured empty + + The served `info.version` has always carried the API version identifier + (`api.version`, default `'v1'`), under a comment claiming it carried "the + runtime version so consumers don't pin to the spec package's compile-time + version". Both halves were false: the runtime version never reached the field, + and the `|| enriched.info.version` fallback published exactly the compile-time + version the comment said the line existed to avoid. + + The fallback was reachable rather than dead, though not because the contract + permits it: `RestApiConfigSchema` declares + `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which refuses `''`. Nothing + parses this config against that schema — both hops into the server are casts — + so `normalizeConfig`'s `??` is the only guard, it does not catch `''`, and the + document advertised `@objectstack/spec`'s package version. It now serves the configured value as + written, so a misconfigured deployment stays visibly misconfigured instead of + silently switching the field to a different kind of fact. Every non-empty + `api.version` — including the default — serves exactly what it served before. + + `info.version` is deliberately not the runtime version: OpenAPI 3.1 defines it + as "the version of the OpenAPI document (which is distinct from the OpenAPI + Specification version or the API implementation version)". Callers who want the + serving artifact read `{basePath}/discovery` or `/health`. +- 14b1145: fix(rest): the direct-mount package door carries a demoted producer code on `declaredCode` (#12405) + + `GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` + and `DELETE /api/v1/packages/:id` now put a producer's own error-code spelling on + the wire's `error.declaredCode` when the closed ADR-0112 vocabulary did not admit + it. Previously that spelling was resolved and then dropped: `sendThrownError` + (`packages/rest/src/package-routes.ts`) asked `resolveThrownHttpError` for the + answer — which returns `declaredCode` exactly when the demote happened — and then + forwarded only `details` to the shared envelope writer. + + Nothing invalid shipped, which is what made the loss silent and one-directional: + the closed `code` still carried the member the HTTP status derives, so every body + parsed, while an author's spelling vanished and a consumer told by ADR-0112 to read + `declaredCode` found nothing there. + + This ADOPTS the rule two sibling doors already apply rather than inventing one — the + demote is read through `demotedDeclaredCode`, the single definition of "presence + means demotion", exactly as the dispatcher's `errorFromThrown` + (`packages/runtime/src/http-dispatcher.ts`, #9106) and the flat `/data` door's + `thrownCodeFields` (`packages/rest/src/error-response.ts`, #9232) do. That matters + here specifically: the runtime dispatcher domain is the TWIN transport for + `/api/v1/packages`, and it has emitted this channel all along, while this + direct-mount registrar — which registers first and is therefore the one production + serves for the three routes both declare — dropped it. One path, two doors, + disagreeing on a declared channel. + + Additive and shape-preserving. A REGISTERED producer code still carries no + `declaredCode` (repeating it would put two spellings of one fact on every refusal), + a producer that declared no code still carries none, `details` is untouched, and + `code`/`status`/`message` are byte-identical to before on every existing path. The + 5xx message withhold is unchanged and does NOT suppress the demote: that withhold is + scoped to the prose by its own contract (`status`, `code` and `details` untouched), + `declaredCode` is a code channel, and the twin applies no status condition to it + either. + + ⛔ `userMessage` is deliberately NOT threaded here — the shared `sendError`'s `extra` + has admitted it since #12404, and that channel is threaded separately, in #12502. +- 092b9da: fix(rest): the direct-mount package door carries a producer-marked `userMessage` (#12502) + + `GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` + and `DELETE /api/v1/packages/:id` now put a producer's user-facing refusal text on + the wire's `error.userMessage` when the throw carried one. Previously that text was + resolved and then dropped: `sendThrownError` (`packages/rest/src/package-routes.ts`) + asked `resolveThrownHttpError` for the answer — which returns `userMessage` exactly + when the producer marked a non-empty string at throw time (#9934) — and then + forwarded only `details` and `declaredCode` to the shared envelope writer. + + Nothing invalid shipped, which is what made the loss silent: `code`, `status` and + `message` were all correct, so every body parsed, while an author's deliberate, + end-user-addressed sentence vanished and a consumer told by ADR-0112 to render + `userMessage` verbatim found nothing there and fell back to its generic + substitution — the #3821 behaviour this channel exists to override. + + This completes the pair the sibling change left open. That one threaded + `declaredCode` and said `userMessage` was "tracked separately"; this is it, and the + `extra` it spreads into has admitted the field since #12404. + + ⛔ The idiom is the INVERSE of the sibling's, deliberately. `declaredCode` must be + read through `demotedDeclaredCode` because its raw field carries a second meaning — + it is also set when the producer's spelling IS the registered member, so forwarding + it raw would put two spellings of one fact on every registered refusal. + `userMessage` has no second meaning: `declaredUserMessage` already decided what + counts as marked (a non-empty string, or nothing), so the caller passes + `thrown.userMessage` straight through, byte for byte what the dispatcher twin + serving this same path does (`errorFromThrown`, + `packages/runtime/src/http-dispatcher.ts`). Consumers must not read presence as + anything but "the producer opted in". + + Additive and shape-preserving. An unmarked refusal still carries no `userMessage`, + and the three shapes `declaredUserMessage` rejects — `''`, whitespace-only, a + non-string — still carry none, so nothing invents a marked message for a producer + that never wrote one. `details`, `declaredCode`, `code`, `status` and `message` are + byte-identical to before on every existing path. + + The 5xx message withhold is unchanged and does NOT suppress the mark: that withhold + rewrites a local `message` const and `looksLikeInternalErrorLeak` is only ever handed + `thrown.message`, so the marked channel is never an input to it. The two are + answering different questions — leaked diagnostic prose is withheld, while the + producer's own sentence to the end user discloses only what it chose to — and the + ruling that created the channel made it status-agnostic on purpose. + + ⚠️ Stated because it is the honest cost, and so the next channel added here does not + have to rediscover which bar applies: the IN-TREE producer set at this door is empty, + for this channel and for `declaredCode` alike. This door is judged live because it is + **composed rather than closed** — `resolvePackageService()` and the `protocol` slice + are open composition points whose throws all four handlers forward verbatim, and + ADR-0112's federation amendment exists precisely because the producer set is not + enumerable in-tree. The live population is the injected/federated limb, which is the + population the new pins in `packages/rest/src/package-door-user-message.test.ts` + drive. +- da43fde: `GET /api/v1/discovery` reports the serving artifact's version instead of the + URL path segment the caller just typed + + `registerDiscoveryEndpoints` called the producer and overwrote the answer one + line later: + + ```ts + const discovery = await protocol.getDiscovery(); + + // Override discovery information with actual server configuration + discovery.version = this.config.api.version; + ``` + + `config.api.version` is the **API version identifier**, not an artifact + identity. `normalizeConfig()` defaults it to `'v1'`, `packages/spec`'s + `plugin-rest-api.zod.ts` describes it as "API version identifier", and the same + value builds the mount — `getApiBasePath()` returns + ``api.apiPath ?? `${api.basePath}/${api.version}` `` → `/api/v1`. So on every + REST-served host, `GET /api/v1/discovery` answered `version: "v1"`: the segment + the caller had already typed to reach the endpoint, on every build of every + release, forever. + + `DiscoverySchema` declares `version` under **System Identity**, grouped with + `name` and `environment` — the "what server is this" question. The #10993 + ruling settled that reading and #11235/#11242 reaffirmed it. The override is + now gone and the producer's derived value reaches the wire. + + **What changes on the wire.** `version` on this endpoint was `"v1"` and is now + the value `getDiscovery()` derives: `OS_RUNTIME_VERSION` when a deployment or + build pipeline stamps one, else the resolved `@objectstack/metadata-protocol` + package version, else `"unknown"`. That is the same stamp `/health` and the + runtime dispatcher's own `/discovery` already read, so the two discovery + producers now give one answer rather than two dialects of one field. Before + #11297 this override masked two producers that genuinely disagreed (`'1.0.0'` + vs `'1.0'`); after it, it was overwriting a value that already agreed. + + **The API-version fact is not lost.** Every entry in the same document's + `routes` is prefixed with the mounted base path, which is built from + `api.version` — recoverable from the same response, in the field that means it. + No schema change, no new field: the accept set and the public surface are + unchanged, and `api.version` still does its real job of building the mount. + + Pinned in `packages/rest/src/discovery-schema-conformance.test.ts`, which drives + the **real** producer through the **real** handler. The assertions pin + provenance, never a literal version string — a stamp injected by the test must + appear on the wire, and the served value must equal what the producer answers + when called directly, including on a server configured with a different + `api.version` (where `routes.data` is asserted to still carry that segment). A + pin spelling a literal would rot at the next release. +- a933ed7: refactor(rest): the audit and reset door call sites are compiled against the declared contract (#11678, #11679) + + The `GET /meta/:type/:name/audit` and `DELETE /meta/:type/:name` doors in + `packages/rest/src/rest-server.ts` reached their protocol methods through + `(p as any)` — once for each feature-detection guard, once for each call — so + the compiler checked nothing about the request literals they built. The two + casts were load-bearing in opposite ways, both measured: the audit door's on + **member existence** (`auditMetaItem` was undeclared in `packages/spec` + entirely — removing the cast answered `TS2339`), the reset door's on **request + shape** (`deleteMetaItem` was declared, but its request schema carried 2 of the + 8 members the door sends — removing the cast answered `TS2353` on six keys). + + With `MetadataProtocol.auditMetaItem` declared and + `DeleteMetaItemRequestSchema` caught up (the spec half of this landing), the + guards are now `typeof p.auditMetaItem !== 'function'` / `if (!p.deleteMetaItem)` + and each request is a named const typed against the spec contract — the reset + door through `TransportScopedMetaRequest` (it still + spreads the transport-level `environmentId`, which stays layered on by the + #9741 envelope rather than becoming a protocol key), the audit door as a plain + `AuditMetaItemRequest` (it stopped sending `environmentId` when #8747 scoped + the read, so there is no transport member left to layer on). + + **No behaviour change of any kind, and nothing about the wire moves.** The + outgoing payloads are byte-identical (same keys, same conditional spreads); the + edits hoist each literal into a const and drop type-level casts. The 501 + feature-detection guards survive on purpose: both members are declared + **optional** (a kernel may implement neither door), and each guard is also what + narrows its member to callable at the call site. An undeclared key in either + literal is now a compile error instead of a payload member no contract has ever + seen. +- 6202043: Stop answering a terminal `404 RESOURCE_NOT_FOUND` for a REGISTRY read that + could not happen on `GET /api/v1/packages/:id` + + The detail door tries the durable `sys_packages` read first and falls back to + the in-memory registry via `protocol.getMetaItems({ type: 'package' })`. That + fallback sat in a bare `catch {} // Protocol unavailable`, so when the read + threw, control fell straight through to the line below and the door answered + **`404 RESOURCE_NOT_FOUND` — `Package "" was not found.`** + + This is the worse half of the family, not a smaller one. The list door's + version of the same swallow (#11130) answered a `200` whose `total` + under-counted; this one answers a terminal negative fact. `404` / + `RESOURCE_NOT_FOUND` is not "the answer may be incomplete", it is *"this package + does not exist"*, and callers act on it: an installer decides the package is not + installed and offers to install it, a console hides the entry, a script branches + to the create path. The producer's own words for the same condition are the + opposite — *"whether this item exists is unknown"*. + + It was also #5532's defect resurfacing one layer up. + `ObjectStackProtocolImplementation.getMetaItems` was taught **not** to report an + unreadable `sys_metadata` as "that item does not exist"; this consumer-side + catch then re-applied precisely that relabelling to the protocol's answer. So + the producer already declares the refusal — every non-benign overlay read + failure leaves as `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code on + the error — and the repair is the same one #11063 made for this door's durable + half and #11130 made for the list door's registry half: delete the catch and let + `sendThrownError` carry the producer's own status and code. + + Standing family ruling — #10965 · #10677 · #10789 · #11063 · #11130: **a read + that could not happen must not be reported as a read that found nothing.** + + Unchanged, and pinned in both directions because the defect was that a failed + read and an absent resource were indistinguishable: a genuine miss (both sources + read fine, neither holds the id) still answers `404 RESOURCE_NOT_FOUND`; a + composition with no protocol service is an absence rather than a failed read and + still reaches that same 404; a registry hit still answers `200` with + `source: 'registry'`; and a durable hit still answers `200` without consulting + the registry at all. No wire field is added — the response shape is a contract + decision this change does not carry. +- 8519095: **Fix:** `GET /api/v1/packages` no longer absorbs a failed **registry** read into a `200` — the other half of the two-source merge #11063 closed (#11130). + + The list door merges two sources: the in-memory registry, read through `protocol.getMetaItems({ type: 'package' })`, and the durable `sys_packages` rows, read through `PackageService.list()`. #11063 stopped the door swallowing a failure of the durable half. The registry half still carried its own bare `catch {}` commented *"Protocol unavailable — continue with database only"*, so the exact ambiguity #11063 closed stayed open on the other source: when `getMetaItems` threw, the door answered `200` with `{ packages, total }` built from the database alone, and `total` was presented as a **complete count** either way. The surviving entries kept `source: 'database'`, which reads as provenance rather than as a warning that the registry half is absent — nothing on the wire separated *"these are all the packages"* from *"these are the packages I could still see"*. Same standing family ruling as its sibling — #10965 · #10677 / PR #10788 · #10789 / PR #10964 · #11063: **a read that could not happen must not be reported as a read that found nothing.** + + **The producer already declared its refusal, so this is #11063's edit rather than a new posture.** The card left open whether a *present* `getMetaItems` even has a reachable throw. Measured on the live implementation — `ObjectStackProtocolImplementation` in `packages/metadata-protocol`, the object `MetadataProtocolPlugin` registers as the `protocol` service and the one `RestApiPlugin` hands to this registrar: it routes every non-benign `sys_metadata` overlay read failure through `rethrowUnlessMetadataStoreUnprovisioned` → `metadataStoreUnavailableError`, i.e. `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code **on the error** (#5532) — the same envelope #10965 gave `PackageService.list()`. Driving it with the door's own request answers `THREW status=503 code=SERVICE_UNAVAILABLE` on an `ECONNREFUSED` store. So route (b)'s first leg was already landed and only the second remained: stop swallowing. + + **What changes on the wire.** A registry read that refuses now reaches the client as the producer's own `503` / `SERVICE_UNAVAILABLE` through the declared envelope, instead of a `200` whose `total` lied. A registry read that throws something *undeclared* is answered as the `500 INTERNAL_ERROR` a fault deserves, instead of being eaten. Both were previously indistinguishable from a healthy, complete listing. + + **What deliberately does not change.** An **absent** protocol service is a different, already-handled case — the `if (options.protocol && typeof … === 'function')` guard is untouched, so a composition without the protocol service still answers `200` with the durable half alone. First boot is unaffected: `sys_metadata` not being provisioned yet is not a throw on that path (`isMissingTableError`), it is an empty overlay. The healthy merge — two sources, `source: 'registry' | 'database' | 'both'`, a truthful `total` — is byte-identical. The durable half is untouched (#11063 landed it), and so is the dispatcher twin in `packages/runtime/src/domains/packages.ts`, which serves a single in-memory read with nothing of this shape. + + **No response field is added.** The card's third shape — keep the `200` and make the tolerance visible with a partial-result marker — is a response-shape change, i.e. a contract decision, and was not authorized here. + + Pinned by `packages/rest/src/package-list-registry-read-refusal.test.ts`, which asserts the mechanism rather than "the door still answers": the status **and** the declared `code`, that no `total` is reported over a read that failed, that both halves of the one merge answer the same outage identically, and — as the overreach guards — that an absent protocol service and a healthy merge are unmoved. On the pre-fix tree 4 of its 6 cases fail, every one of them reading `200` where a refusal was owed. +- 6e33394: refactor(rest): the `publishMetaItem` call site is compiled against the declared contract (#11145) + + The `POST /meta/:type/:name/publish` door in `packages/rest/src/rest-server.ts` + reached its protocol method through `(p as any).publishMetaItem` — once for the + 501 feature-detection guard, once for the call — so the compiler checked nothing + about the request literal it built. The cast was load-bearing on **member + existence**, not on request shape: #10350 measured that deleting it answered + `TS2339: Property 'publishMetaItem' does not exist on type 'RestProtocol'`, not + a `TS2353` about an unknown key. `publishMetaItem` was an ADR-0076 D9 + server-only extension, so no amount of widening the implementation's own + parameter type in `@objectstack/metadata-protocol` (which this package + deliberately does not depend on) could have retired it. + + #11006 (maintainer ruling 2026-08-22, option B) declared the member on + `MetadataProtocol` with a `PublishMetaItemRequest`, which is what removes the + prop. The guard is now `if (!p.publishMetaItem)` and the request is a named + const typed `TransportScopedMetaRequest` — the same + shape #9741 gave the meta-read doors and #9805 gave the non-door helpers. + + **No behaviour change of any kind, and nothing about the wire moves.** The + outgoing payload is byte-identical (same keys, same conditional spreads); the + edit hoists the literal into a const and drops a type-level cast. Two things + deliberately survive: + + - the 501 feature-detection guard, because the declared member is **optional** + (a kernel may not implement the promotion door at all) — and it is also what + narrows the member to callable at the call site; + - the transport-level `environmentId`, which stays layered on by the + `TransportScopedMetaRequest` envelope rather than becoming a protocol key, per + the #9741 ruling (2026-08-18). + + What the typing buys, measured rather than asserted: an undeclared key in this + request literal is now `TS2353` at compile time instead of a payload member no + contract has ever seen. The docblock that existed only to explain why the cast + had to stay is replaced rather than left behind — a rationale for a prop that no + longer exists is a declaration that outlived its subject. +- dc75ba8: feat(spec,client): bind published response contracts for the 17 unbound client-SDK methods; retire the false `PackageRollbackResponseSchema` (#12038, ruling 1C · 2C · 3A · 4A · 5A) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` `api/PackageRollbackResponse` + plus the D3 semantic entry `package-rollback-response-retired` — where + `os migrate meta` users will look). + + FROM → TO: + + - `PackageRollbackResponseSchema` / `PackageRollbackResponse` / + `PackageRollbackResponseParsed` → `RollbackToPackageCommitResponseSchema` / + `RollbackToPackageCommitResponse` (`@objectstack/spec/api`). The retired + schema declared a VERSION rollback (`{ success, restoredVersion?, + message? }`) while the live `POST /packages/:id/rollback` route posts + `{ commitId }` and answers the ADR-0067 COMMIT rollback — + `{ success, revertedCommits: string[], failed: [{ commitId, error }] }`. + Read `revertedCommits` / `failed`; there is no `restoredVersion`. + - `PackageApiContracts.rollbackPackage` → *(removed)* — it bound the + wrong-operation schema to the exact live path. No route registration or + SDK generation ever consumed it (zero consumers measured across + objectstack, objectui and cloud; only its own unit test and the #11925 + compile-time guard, both updated in this PR). + + One-line fix: replace any import of `PackageRollbackResponse(Schema)` with + `RollbackToPackageCommitResponse(Schema)` and read `revertedCommits` / + `failed` instead of `restoredVersion`. `PackageRollbackRequestSchema` stays + published (ruled out of the retirement), bound to no route. + + The rest of the change is additive — the recorded five-part maintainer + ruling (2026-08-27) for the 17 client-SDK methods that had no published + response contract: + + - **12 describe-only transcriptions** into `@objectstack/spec/api`, each + from the return type its producer already declares inline (no wire byte + changes): `ListDraftsResponseSchema`, `GetMetaDiagnosticsResponseSchema`, + `FindReferencesToMetaResponseSchema`, `RollbackMetaItemResponseSchema`, + `DiffMetaItemResponseSchema`, `ResolvedBookSchema` (authored beside its + interfaces in `system/book.zod.ts`), `DiscardPackageDraftsResponseSchema`, + `ListPackageCommitsResponseSchema` (the `{ commits }` wrapper declared as + the handler's own), `RevertPackageCommitResponseSchema`, + `RollbackToPackageCommitResponseSchema`, + `ReassignOrphanedMetadataResponseSchema`, `DuplicatePackageResponseSchema`. + - **Ruling 1C**: `GetPublishedMetaItemResponseSchema` is deliberately opaque + (`z.unknown()`) — the route answers an arbitrary metadata item body, never + a union frozen against the type registry. + - **Ruling 2C**: `meta.migrateStored` stays UNBOUND, documented at its two + ledger rows and in the SDK — `StoredMigrationReport` lives in + `@objectstack/metadata-protocol`, and a second declaration would drift. + - **Ruling 4A**: `PackageExportManifestSchema` pins the four fixed keys + (`id`, `name`, `version`, `label?`) and stays honestly open for the + registry-derived plural keys. + - **Ruling 5A**: `PackagePublishResultSchema` and the `ResolvedBook` family + are re-exported into `@objectstack/spec/api` (the namespace the + route-ledger resolver searches) — never a second copy. + - The 18 boundable route-ledger rows in `@objectstack/runtime` and + `@objectstack/rest` now name their `responseSchema`, each stating which + surface's envelope it describes; every named schema carries conformance + coverage (the #3877 rule). + - The client SDK binds 16 of the 17 methods to the published payload types, + replaces four invented test mocks with producer-true shapes, and pins the + `unwrapResponse` mis-unwrap hazard so no bound payload can declare both a + boolean `success` and a `data` key. +- 1e4d2eb: fix(rest): the record-share family carries a demoted producer code on `declaredCode` (#12510) + + `GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now + put a producer's own error-code spelling on the wire's `error.declaredCode` when + the closed ADR-0112 vocabulary did not admit it. Previously that spelling was + resolved and then dropped at the re-dress: `respondSharingError` + (`packages/rest/src/rest-server.ts`) asks `classifiedRefusalAnswer` — the flat + `/data` door's own classification, which already carries the demoted string — + and forwarded only `status`, `code` and the message into the nested ADR-0112 D5 + envelope. + + Nothing invalid shipped, which is what made the loss silent and one-directional: + the closed `code` still carried the member the HTTP status derives, so every + body parsed, while an author's spelling vanished and a consumer told by ADR-0112 + to read `declaredCode` found nothing there. Measured before the repair, one + producer through both doors: a thrown `{ code: 'CLOSE_PERIOD_LOCKED', status: + 409 }` answered `409 RESOURCE_CONFLICT` at both, with `declaredCode: + 'CLOSE_PERIOD_LOCKED'` at `/data` and nothing at the share door. + + This ADOPTS the rule the sibling doors already apply rather than inventing one. + The demote is `demotedDeclaredCode`'s answer — the single definition of + "presence means demotion" — reached here through the classification's own + `declaredCode`, which the flat door computes with exactly that function + (`thrownCodeFields`, `packages/rest/src/error-response.ts`, #9232). The pair is + carried, not recomputed: this door asks the classification once and re-dresses + that one answer, as it already does for `status`, `code` and the message. + + Additive and shape-preserving. A REGISTERED producer code still carries no + `declaredCode` (repeating it would put two spellings of one fact on every + refusal), a producer that declared no code still carries none, a non-string + `code` is still context rather than a wire spelling, and `status`, `code` and + `message` are byte-identical to before on every existing path. The five-prefix + ADR-0111 idiom and the family's own `SHARES_LIST_FAILED` / + `SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` 500 terminal are untouched. + `ApiErrorSchema` has declared the field as optional since #9106, so the + contract's accept set does not move. +- b853cf3: fix(rest): the record-share family carries a producer-marked `userMessage` (#12669, fork (a)) + + `GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now + put a producer's own caller-facing refusal text on the wire's + `error.userMessage`. Previously that sentence was classified and then dropped at + the re-dress: `respondSharingError` (`packages/rest/src/rest-server.ts`) asks + `classifiedRefusalAnswer` — the flat `/data` door's own classification, which + attaches the mark in `withDeclaredUserMessage` (#9934) — and forwarded only + `status`, `code`, the message and, since #12510, `declaredCode` into the nested + ADR-0112 D5 envelope. + + Nothing invalid shipped, which is what made the loss silent and one-directional: + every body parsed as `ApiErrorSchema`, while a console told by ADR-0112 to render + `userMessage` verbatim found nothing at this door and fell back to its generic + substitution — for the same throw the twin door rendered. Measured before the + repair, one producer through both real routes: a thrown `{ code: + 'CLOSE_PERIOD_LOCKED', status: 409, userMessage: 'Ask finance to reopen the + period.' }` answered `409 RESOURCE_CONFLICT` at both doors, with the sentence at + `/data` and nothing at the share door. + + The population is wider than its `declaredCode` neighbour's and the two are + deliberately not symmetric. `declaredCode` is read from the classification + because presence there MEANS demotion, an invariant a caller would otherwise + re-derive (#12510). `userMessage` has no such invariant: `declaredUserMessage` + already decided presence — a non-empty string, or nothing — and + `truncateClientMessage` already applied #5423's bound, so the classification's + own field is carried straight through. A REGISTERED code demotes nothing and so + carries no `declaredCode`, and still carries its author's sentence. + + Additive and shape-preserving. An unmarked refusal, an empty or whitespace-only + mark and a non-string one still carry no key; `status`, `code` and `message` are + byte-identical to before on every existing path; the five-prefix ADR-0111 idiom + and the family's own `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / + `SHARE_REVOKE_FAILED` 500 terminal are untouched. `ApiErrorSchema` has declared + the field as optional since #9934, so the contract's accept set does not move. + + Fork (b) of #12669 — mapping the flat dialect's top-level `issues` onto the + nested envelope's `ApiError.details` — is a shape decision on a contract field + and is deliberately not shipped here; #12669 stays open on it. +- 4af6c44: fix(rest): the record-share family carries a producer-marked `userMessage` on its two non-classified exits (#12693) + + `respondSharingError` learned to carry the producer's caller-facing sentence at + its classified re-dress (#12669 fork (a)). The family has **two other exits** + that never reach that classification and still dropped it: + + - the **500 fault terminal** (`SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / + `SHARE_REVOKE_FAILED`) — `classifiedRefusalAnswer` deliberately hands a + declared or resolved 5xx back to "the catching route's own terminal", so a + marked fault never had a classification to ride; + - the **ADR-0111 message-prefix arm** — it runs precisely when the + classification answered nothing. + + Measured on `15bf9e859` before the repair, one marked producer per exit driven + through the real routes on both doors: + + ```text + throw { code: 'SHARE_STORE_DOWN', status: 503, userMessage: '…' } + share door : 500 SHARES_LIST_FAILED — no mark + /data door : 503 SERVICE_UNAVAILABLE — mark carried + throw Error('NOT_FOUND: no such record …') + userMessage + share door : 404 NOT_FOUND — no mark + /data door : 500 INTERNAL_ERROR — mark carried + ``` + + Nothing invalid shipped — every body parsed as `ApiErrorSchema` — which is what + made the loss silent and one-directional: a console told by ADR-0112 to render + `userMessage` verbatim found nothing at these two exits and fell back to its + generic substitution, for the same throw the twin door rendered. + + Neither exit holds a `refusal.body`, so the classified arm's expression is not + reusable at either. `error-response.ts` now exports + `boundedDeclaredUserMessage` — `declaredUserMessage`'s presence answer with + #5423's bound applied, lifted out of the private `withDeclaredUserMessage` + wrapper so a caller with no body to merge into can ask the same rule rather + than open-code it. The flat `/data` door is unchanged and goes on calling it + through that wrapper. + + ⛔ Only the mark is added. Every existing key keeps its value and position at + both doors (measured: 32 route/door answers before and after, 0 statuses moved, + 0 existing keys moved or changed, 24 gaining exactly `userMessage`). The three + deliberate share-vs-`/data` differences visible in the same measurement stay + exactly as they are — the family still folds a declared `503` into its own + `500`, still interpolates the caught message where `/data` withholds 5xx prose + per #5437, and `/data` is still not taught this service's local prefix idiom. + + No in-tree producer sets `userMessage` at this seam today + (`plugin-sharing` = 0 hits; positive control `throw ` = 25 files), so this + wires a declared channel rather than repairing a live loss. +- cad8b42: Serve the `code` a sandboxed hook declared on `/api/v1/data` refusals, at every + status — not only where a bespoke arm happened to catch the condition first + + Measured on a booted 17.1.0 server: a hook throwing + `Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })` reached + the client as `409 {"error":"…","object":"crm_opportunity"}` — the status but no + machine-readable `code`. Same for `DUPLICATE_VALUE` on `POST` and `FORBIDDEN` on + `403`, while `DELETE_RESTRICTED` at the same 409 and `VALIDATION_FAILED` at 400 + carried theirs. A client that must tell "this record is frozen, do not retry" + from "this value is already taken, offer a merge" got `409` for both and had to + substring-match prose that is localised and deliberately reworded over time — + the failure mode the ADR-0112 `code` vocabulary exists to remove. + + The branch is `classifyDataError`'s **sandbox unwrap door** in + `error-response.ts` (`typeof error?.innerMessage === 'string'`), which rendered + from the raw error and emitted no `code` at all, while every arm around it + renders from the resolved envelope. That is the whole of the reported + correlation between "no `code`" and "the unwrapped message": they are one + branch, not cause and effect. + + It was never the status policy it looked like from outside. The door dropped + `code` on a declared **400** exactly as on a declared 409, and kept it on a + declared 5xx by falling through to the passthrough below — one sandboxed + producer, its code surviving 503 and lost at 409. What made the reading look + status-shaped is which codes have a bespoke arm above the door: + `DELETE_RESTRICTED` and `VALIDATION_FAILED` do and never reach it, + `RECORD_LOCKED` / `DUPLICATE_VALUE` / `FORBIDDEN` do not and did. + + The code now rides via `thrownCodeFields`, the one definition the three sibling + arms already use, so the door joins the closed ADR-0112 vocabulary: a registered + spelling arrives verbatim, an unregistered one is demoted to `declaredCode` + beside the status-derived member. Nothing is invented — a producer that declared + no code still gets a body carrying none. + + Unchanged: the business message is still the unwrapped `innerMessage` (never the + `hook 'x' threw: …` debug wrapper), a crashing hook body is still the sanitised + `500 INTERNAL_ERROR`, a declared 5xx still withholds its prose, and a refusal + that declares no status still answers 400. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/service-package@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 72f6eaee2f..5b98fff059 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.2.0", + "version": "17.3.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 1d350b0941..cd4633cf84 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,1120 @@ # @objectstack/runtime +## 17.3.0 + +### Minor Changes + +- 4bd6faa: feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) + + The substrate step (§10.3) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation + machinery once, before the grants cache (#11967) that will consume it, so that + leg does not carry it. **Nothing here caches anything.** + + - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine + middleware seam on every `insert` / `update` / `delete`, ahead of the whole + chain (and so ahead of any `isSystem` bypass a middleware applies). It + generalises the private counter `@objectstack/plugin-security` has carried + since #10757: the mechanism was always the engine's, and hoisting it lets a + second consumer share **one** signal instead of minting a parallel one that + watches a different set of writes. A seam rather than a list of call sites, + because a forgotten call site fails as silent over-permission and writing + through the engine is the only way to write at all — including better-auth's + own adapter. + - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged + in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a + consuming cache carries is the correctness contract; this channel is not.** No + shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a + missed message is *expected*, the bridge stays out of the write path (a + publish failure is logged and swallowed, never awaited by the writer), and the + channel only moves the *typical* convergence from one TTL to one network hop. + That statement lives in the code at the channel, where a consumer reads it. + - **The boot-time posture statement** — non-optional by the ruling. Whenever a + grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no + cross-node invalidation bus, the deployment is told so at `warn`, every boot, + naming the window it accepted and the remedy. It is a statement, not a + refusal: a TTL-bounded per-process cache is a legitimate configuration. It is + said out loud because a silently-absent invalidation bridge is how a security + control gets disabled with nobody noticing (#4785). The in-process `memory` + driver counts as **no** bus — a cluster service exists on the shipped default + while fanning out to nobody, which is the case a "is a cluster service + registered?" check answers `yes` to and is wrong about. + + **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero + subscribers, so nothing is published and nothing is invalidated; with the + shipped default TTL of `0` the bridge attaches nothing and logs nothing above + `debug`. The one composition change worth naming: `Runtime` now registers + `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` + — that is not an oversight, it is the loudest case the posture check has, and + skipping it there would put the statement's absence exactly where the missing + bus is. + + `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads + the engine's epoch when the wired engine exposes one and keeps its private + counter otherwise (test doubles, embeddings). The covered set of writes is + identical — the plugin's own middleware was already global — and it is now + identical *by construction* rather than by two files agreeing on which + operations count. +- 266436a: **BREAKING (authorization):** `POST /api/v1/automation/:name/toggle` now requires the `manage_metadata` capability. A caller that holds a session but not that capability is answered **403 `PERMISSION_DENIED`** where it previously received **200** with the flow's enablement changed. + + This narrows what the API accepts, so it ships as `minor` with the breaking surface named rather than as a `patch`. + + + + **The exact surface that moves** + + | | before | after | + |---|---|---| + | authenticated caller **with** `manage_metadata` | 200, flow toggled | 200, flow toggled — unchanged | + | authenticated caller **without** it | 200, flow toggled | **403 `PERMISSION_DENIED`**, `toggleFlow` never entered | + | anonymous caller | 401 | 401 — unchanged, the #5519 floor still answers first | + | engine self-invocation (`isSystem`) | 200 | 200 — unchanged | + + Nothing else on the domain moves. The execution doors keep their posture: `POST /:name/trigger`, the legacy `POST /trigger/:name` and `POST /:name/runs/:runId/resume` are untouched, so ordinary members can still run the flows built for them. The reads are untouched. `GET /automation/_status` still serves enablement to any authenticated caller — this change is about mutating the bit, not observing it. + + **Why enablement joined the metadata write set** + + #10145 gated the automation definition writes (`POST /`, `PUT /:name`, `DELETE /:name`) and deliberately left `toggle` out in the open, because whether disabling a flow is authoring or operating is a product call. It was filed, measured over HTTP, and ruled on 2026-08-23. + + The measurement is why "it is engine state, so leave it" did not survive: **the enabled bit is not a row, so no organization wall scopes it.** `toggleFlow(name, enabled)` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads that same map with no caller and no organization, and the automation service is one instance per environment. On a real, non-degraded `isolated` posture, a tenant org owner without the capability — refused 403 by `PUT /meta/:type/:name`, `POST /automation` and `DELETE /automation/:name` at the same session — switched a shipped flow off, and an unrelated tenant in a **different organization** plus the platform admin both read it off, symmetrically in both directions. Disabling a shipped flow is functionally equivalent to deleting it for as long as it stays off, and `DELETE /:name` was already gated. Mitigating but not exculpating: the override is process-local, so a cold boot reads `enabled: true` again. + + **No new capability name was minted.** The change is one arm on the existing `isFlowAuthoringWrite` predicate in `packages/runtime/src/domains/automation.ts` — the #10145 author wrote that as a single function precisely so this ruling would be one edit rather than a fourth copy of the policy. Fail-closed by construction, exactly like its three siblings: an absent `executionContext`, an absent `systemPermissions` or an empty one all refuse, and the gate runs ahead of the body checks so a refused caller learns nothing about the toggle contract. + + **Migration.** A caller that toggles flows programmatically — `client.automation.toggle(name, enabled)` — must present a principal holding `manage_metadata`; the same capability its `create` / `update` / `delete` neighbours have required since #10145. No caller of this route was found in this repo, in the Console UI (`objectstack-ai/objectui`, which posts only `/trigger` and `/resume` and merely *displays* enablement), or in the example apps, so the expected migration surface is programmatic SDK callers rather than end-user UI. +- 1524927: Packaged flows can be switched off durably, and the process-local off-switch is retired + + Disabling a packaged flow now writes an install-level row to the + `sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a + process-local map. The engine consults that ledger at the `execute()` seam — + the one seam every entry path crosses (record-change, schedule, time-relative, + api, subflow) — and refuses a disabled flow there with the existing + `FLOW_DISABLED` code; the ledger case is distinguished by the message, so no + new error code joins the ADR-0112 ledger. An install-level disable also unbinds + the flow's trigger, and re-enabling rebinds it. Absence of a row means the + packaged default, active, so a deployment that never flips anything behaves + exactly as before. + + This retires the mechanism behind #10243 rather than refining it. The old + `flowEnabled` map was not a row, so no organization wall scoped it: on a walled + multi-organization deployment a tenant org owner could switch a shipped flow + off environment-wide and an unrelated tenant read it off. The durable row + replaces it, and because a durable install-wide switch writable by tenants + would be that leak with persistence, the write is now authority-gated: + `POST /automation/:name/toggle` requires the platform operator in the `group` + and `isolated` postures, while the `single` posture — where install-level and + org-level are the same scope — is unchanged for the org admin who already holds + `manage_metadata`. The refusal names the posture and points at the clone path. + + Disabling a flow that packaged flows still call as a subflow is refused, and + the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks + mid-run at its subflow node with an inexplicable late failure. The check is a + definition scan at disable time over both `subflow` and `map` nodes; no + reference index is built. Enabling is never guarded. + + One behaviour change worth calling out: a disable now survives + unregister-and-re-register, which is what a package upgrade, a Studio publish + and the boot pull all do. ADR-0126 §6 requires it — the ledger records the + customer's choice, and no upgrade un-makes a choice — but it is the opposite of + what the retired in-process map did, where any re-registration silently + re-armed the flow. +- 8542bd4: **Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + + A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + + What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + + ```js + // this record — nameField, formula or stored column alike + await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); + // a related record, through the lookup column that holds its id + const account = await ctx.title('account_id'); + ``` + + **Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + + **Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + + **It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + + Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. +- 2af5eac: fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277) + + A hook that stripped a field from its input with `delete` did nothing, on BOTH + execution paths, while an assignment made two lines above it on the same object + in the same call landed normally. Nothing raised, and nothing in the platform + reported it. + + Graded `minor` rather than `patch` deliberately: it moves data that reaches + downstream consumers. Any shipped hook that already contains + `delete ctx.input.` has been a no-op until now and starts taking effect + on upgrade — which is the point, and is also exactly why it must not arrive as + a silent patch. No API is removed and no accept set narrows. + + ### The two mechanisms, which were unrelated and produced one outcome + + **In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).** + The flat-record `Proxy` a declarative hook receives over the engine's + `{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` / + `getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell + through to `Reflect.deleteProperty` on the WRAPPER, one level above the record, + removing a key that was never there and returning `true`. `set` was trapped and + wrote into `data`, which is what the engine persists; hence assignment survived + and deletion evaporated. + + **Sandboxed (`applyMutationsToInput`, + `packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations + were written home with `Object.assign(target, result.mutatedInput)`. + `Object.assign` copies own enumerable properties and **has no way to represent a + removal**: a key the VM deleted is simply not in the snapshot, and the host's + key stayed. Deletions are now diffed against the entry snapshot and applied + separately. + + Both are fixed in one change on purpose. Closing either alone would make the + same authored `delete` behave differently depending on whether the hook body + runs in-process or in the sandbox — a worse contract than the symmetric silence + it replaced. + + ### What an author could see, before and after + + The sandboxed path is the one with no tell at all. Measured on the pre-fix code, + one hook call, host row alongside: + + ``` + delete ctx.input.internal_notes -> true + 'internal_notes' in ctx.input -> false <- the VM agrees + Object.keys(ctx.input) -> ['subject'] <- ...and so does this + host ctx.input after write-back -> { subject: 'HELP', + internal_notes: 'STAFF-ONLY' } + ``` + + The in-process path was less deceptive than reported, and the correction is + worth having in writing: only `delete`'s own return value lied there. `'k' in + input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key + as present, so an author who checked with anything other than the return value + would have seen the no-op. + + ### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it + + Found while enumerating the trap set, fixed in the same stroke because it is the + strictly worse shape: it defined on the wrapper, and the `get` trap's + fall-through then read the value straight back — so `input.k` CONFIRMED a write + that never reached `data`, while `Object.keys(input)` denied it and the record + never received it. It now routes into `data` like `set` and `deleteProperty` do. + One inherited JS invariant follows: a proxy may not report success for an + explicitly `configurable: false` descriptor its target does not carry, so + `Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws + a `TypeError` where it used to define, silently and uselessly, on the wrapper. + Omitting `configurable` — the common spelling, and the one spread and + `Object.assign` produce — is unaffected. + + ### The direction the sandbox write-back deliberately does not overreach in + + Absence from the exit snapshot is the only evidence a deletion leaves, and on its + own it is ambiguous: a key whose host value is `undefined` (or a function, or a + symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from + the dump without anyone having deleted it. The diff is filtered through the same + JSON lens the boundary uses, so such a key is left alone. Every failure mode of + that probe is conservative — an unprobeable key is simply not deletable — because + losing a delete is recoverable and destroying a field on evidence that was never + there is not. One residual miss follows and is named here rather than discovered + later: a `bigint`-valued key crosses into the VM as a string but is dropped by the + probe, so deleting one is still lost. + + Measured consumer cost of the reported half: a guest-intake app stripped the + fields an anonymous web-to-case / web-to-lead submitter must not write — + internal staff notes, the resolution, the escalation flag, the owner — with + fifteen `delete` statements, every one inert. A submission carrying + `internal_notes` and `resolution` stored them verbatim, and the app's unit tests + stayed green throughout, because they drive the handler with a plain object where + `delete` genuinely works. +- e5ce2ed: Packaged actions can be switched off, on the same activation ledger as flows + + A packaged action can now be disabled for an installation, generalizing the + packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on + the maintainer's amendment ruling 3). The flip writes an install-level row to + the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — + no new table, no new column, no schema change of any kind. Absence of a row + means the packaged default, active, so a deployment that never flips anything + behaves exactly as before, and an empty ledger changes nothing anywhere. + + The consult point is action DISPATCH, and it is present on every door that + dispatches a declared action: the REST `POST /actions/:object/:action` route and + the MCP `run_action` bridge. Both call one shared guard, and a disabled action + is refused `409 ACTION_DISABLED` before anything runs — before the handler body + (which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action + reaches the automation engine, before the param contract is enforced and before + the subject record is read. The refusal names the ledger and the remedies. The + code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and + answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, + which would tell an operator to go looking for a flow that does not exist. + + The consult reads a projection the ObjectQL engine holds and hydrates at boot, + so a disabled action stays disabled across a restart and across the handler + re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — + the ledger records the customer's choice, and nothing re-arms it silently). + + The write door is `POST /actions/_activation/:object/:action` with a + `{ enabled?: boolean }` body. Its first segment is reserved rather than deep in + the path because a machine name can never begin with `_`, so it cannot collide + with an object, an action or a record id. It carries the same two authority + tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture + rule — in the `group` and `isolated` postures the install-wide switch requires + the platform operator, while `single`, where install-level and org-level are the + same scope, is unchanged. That gate is now one implementation shared with + `POST /automation/:name/toggle`; the flow refusal text is unchanged. + + Two refusals are worth knowing about. The ledger addresses an action by its + machine name, so a name declared on more than one object is refused with + `409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off + silently. And a flip that cannot be made durable — no ledger table reachable — + is answered as a failure instead of a 200, because a switch reported as durable + that reverts on the next restart is the failure this whole family exists to + remove. + + Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so + disable is the only primitive here and authoring a new sibling action stays + exactly as it is today. +- c68c670: Add `POST /api/v1/automation/:name/clone` — whole-definition flow clone (ADR-0126 §7.1) + + An admin who cannot edit a packaged flow in place can now copy it to an ordinary + org-authored sibling and edit that instead. `POST /automation/:name/clone` takes + `{ name, label }` — both mandatory — and registers a copy of the source flow's + parsed definition under the new machine name. + + **The copy is whole-definition, never an enumerated facet list.** Every key the + source definition carries comes across; exactly `name`, `label` and `status` are + mutated. This is the shape ADR-0126 §7.1 rules for, and the reason is measured: + a clone assembled from an enumerated facet list silently dropped three of six + facets (#11703) — the record was created, the success toast fired, and the + difference was discoverable only by diffing the two rows. A flow has far more + facets than a permission set, so the acceptance test asserts deep equality of + the cloned definition against its source minus those three fields; a dropped + facet fails the test rather than shipping. + + **The new machine name is mandatory and a same-name clone is refused** with a + 409 `RESOURCE_CONFLICT` naming both the reason and the remedy. Not because + storage rejects it — storage legitimately holds both rows — but because the + automation engine keys flows by bare name, so a second definition under one name + silently shadows the other and which of the two dispatches depends on + registration order. + + **No ancestry is recorded.** Nothing tracks what a clone was copied from — no + provenance field on the definition, none on the response (ADR-0126 amendment + ruling 2, §9). The source's own ADR-0010 protection envelope (`_packageId`, + `_provenance`, `_lock`, …) is dropped rather than carried across, so the clone is + an org-owned flow the admin can actually edit rather than a second copy of the + package's locked artifact. + + **References are not re-pointed** — no reference index exists, so the clone calls + exactly what the original called. The response says so, along with the fact that + `status: 'draft'` is a lifecycle label and not an off-switch: the engine only + disables on `obsolete`/`invalid`, so a cloned record-change or schedule flow is + bound to its trigger and runs alongside the flow it was copied from. + + The route joins the `manage_metadata` authoring-write set (#10145/#10243) — it + registers flow metadata at environment scope, exactly as `POST /automation` does. +- 15eb2c9: feat(security,meta): org-scoped presentation authoring capability `manage_org_presentation` (#12702) + + A tenant org admin in a walled posture can now be granted org-scoped authoring + of exactly the org-overridable presentation types (ADR-0005 tier A: view / + dashboard / report / translation / email_template today — the registry is the + authority) without holding platform-wide `manage_metadata` (maintainer + direction 2026-08-27, quoted in #12701). + + - **spec**: new curated `PLATFORM_CAPABILITIES` entry `manage_org_presentation` + (`scope: 'org'`), seeded into `sys_capability` at boot like its siblings. + Granted by NO shipped permission set — the SaaS operator grants it per + deployment, so existing postures (`single` included) are byte-unchanged by + its existence. + - **metadata-core**: new `metaWriteCapabilityVerdict` — the capability half of + the `/meta` write decision, beside the existing org-scope half + (`organizationIdForMetaWrite`). It admits `isSystem` and `manage_metadata` + exactly as before, and `manage_org_presentation` ONLY when the target type's + registry entry declares `allowOrgOverride: true` (registry-derived via + `declaresOrgOverride`, never a hand-written list) AND the session has an + active organization — the very organization the doors thread, so an admitted + write can only land org-scoped in the caller's own partition: never tier-B, + never env-wide, never another org's. + - **rest**: the four `/meta` item write doors (`PUT` save, `DELETE` reset, + `POST /publish`, `POST /rollback`) run the shared verdict. + `POST /meta/_migrate-stored` stays `manage_metadata`-only — an install-wide + rewrite is env-wide by definition. + - **runtime**: the dispatcher `/meta` `PUT` door runs the same shared verdict + (its `_migrate-stored` twin likewise stays `manage_metadata`-only). + + Refusals keep their transports' existing envelopes (REST `403 FORBIDDEN`, + dispatcher `403 PERMISSION_DENIED`); the tier-B refusal sentence is + byte-identical to before, and the tier-A sentences name the sanctioned path + without disclosing the caller's own grants (#7450). Platform `manage_metadata` + behaviour is unchanged on every door. +- fb5fbb8: Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility. +- 7986d97: Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + + Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. + Stage 1 declared the item-name grammar and refuses every slash-bearing name at + the publish door, so the routes removed here addressed only names that can no + longer be created. + + **BREAKING — three public REST routes stop answering:** + + | stops answering | use instead | + | :-- | :-- | + | `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | + | `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | + | `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + + Each retired route folded its `:section` and `:name` segments back into one + slash-bearing key (`views/all_leads`) that the protocol layer then treated as a + single opaque string — the section half was never stored, filtered or + enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + + The `@objectstack/runtime` dispatcher stops folding in the same way: its + `/meta` handler requires exactly two path segments for an item and three for + `…/published`, instead of re-joining every trailing segment. A `/meta` path + that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than + falling through to the adapter's anonymous 404. + + **FROM → TO for callers.** Address every item through the single-segment route + and percent-encode the name: + + ``` + GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads + ``` + + `@objectstack/client` now calls `encodeURIComponent` on every `/meta` item + address, so SDK callers need no change: the SDK already sends the new spelling. + Encoding is a **no-op** for every name the item-name grammar admits (lowercase + snake_case segments, optionally dot-qualified), so the bytes on the wire are + unchanged for every name that can be written today. + + A pre-grammar **residue** row whose stored name contains a slash remains + readable, writable and deletable: `%2F` matches the single-segment pattern and + the parameter is decoded back to the stored spelling before the handler runs. + Nothing that could be stored has become unaddressable. + + Two SDK doc comments that promised "compound names pass through unencoded" + (`meta.getPublished`, `meta.publishItem`) are corrected, and the + `SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored + at the compound door and published live — is closed at the source: there is one + door, and it reads every member of the options bag. + + + +### Patch Changes + +- 387e231: feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519) + + **BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + Two independent channels could name a post-success destination for one + `type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`, + validated and visible in metadata) and the handler-returned `{ redirectUrl }` + convention (runtime-only). The spec ruled each surface's default in isolation + and said nothing about an action carrying both — so the renderer had to pick, + and the pick lived only in one renderer's implementation (declared `onSuccess` + wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the + doubled channel; ⛔ no `precedence` contract field. + + The measured static-knowability partition: + + - **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is + runtime-only in general (`target` names an opaque registry entry; + `HookBodySchema` declares no return contract) — but `opensInNewTab: true` is + a schema-visible declaration of the handler-redirect channel (its contract is + "pre-open a tab, then drive it to the handler's returned `redirectUrl`"). + A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true` + is now **rejected at parse time**, with guidance naming both channels and the + remedy. Previously the pair parsed clean and one declaration was silently + dead at render. + - **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a + handler that actually returns `{ redirectUrl }` while the action declares + `onSuccess` — now logs a loud `[action-contract]` warning at both dispatch + surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming + the action, both channels, the interim winner and the remedy. Observe-only: + the wire is untouched and the interim renderer precedence stands until the + author takes the remedy. + + Single-channel declarations are untouched and pinned byte-identically: only + `onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and + `opensInNewTab: false` beside `onSuccess` all parse exactly as before. The + corpus was measured at zero doubled producers (this repo's examples and + platform metadata, objectui metadata, and the cloud SSO handoff producers per + the #11519 measurement), so no shipped metadata is affected. + + **Migration.** An action refused by the new refine must pick its one + destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning + `redirectUrl` from the handler), or keep `opensInNewTab` + the handler + redirect and drop `onSuccess`. Which channel is right is an authoring decision + the metadata cannot make for you, and zero such actions exist in any measured + corpus. + + +- 39d625f: AppPlugin's bundle path runs the same ADR-0087 forward conversion as the artifact door + + On an artifact boot the stack-declared security metadata (`positions`, + `permissions`, `capabilities`, `sharingRules`) reached the metadata registry + through two independent readers: the artifact door + (`MetadataPlugin._parseAndRegisterArtifact`), which replays the versioned + ADR-0087 forward conversion before its strict parse, and `AppPlugin`'s ADR-0057 + block, which registered the bundle from `loadArtifactBundle` raw. The two copies + of the same item therefore differed, and which one a consumer saw depended on + registration order. `AppPlugin` now consumes the door's own + `applyArtifactForwardConversions` policy, so both copies carry the canonical + shape for every key the conversion layer governs. +- 7ef0268: fix(metadata,runtime): retire the `policies` dead pointer in both artifact registrars, and pin the map that carried it (#12894) + + Zero behaviour change, by construction. Both readers of an artifact boot carried + a `policies` -> `policy` entry — the artifact door's `ARTIFACT_FIELD_TO_TYPE` + (`packages/metadata/src/plugin.ts`) and `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` + list (`packages/runtime/src/app-plugin.ts`) — and **neither could ever match**. + `ObjectStackDefinitionSchema` is a `strictObject` that declares no top-level + `policies` key, so a definition carrying a `policies` array is refused outright + by the door's strict parse and reaches neither registry. The word is real but + lives one level down: on a permission set `policies` is an alias for + `rowLevelSecurity` (`PERMISSION_SET_KEY_ALIASES`) — a key on an **item**, never a + collection. Both entries are removed, each leaving in place the note the map + already writes for a retirement: what it pointed at, and why it could not match. + + That was the third entry retired from `ARTIFACT_FIELD_TO_TYPE` for exactly this + reason (`themes`, then `roles` -> `positions`, which "matched nothing and + silently dropped compiled positions"). So the deletion ships with the thing the + two predecessors did not have — a check that fails when the pattern recurs: + + - `check:stack-collection-maps` now reconciles **eight** hand-maintained + enumerations against the schema, not seven. `SECURITY_FIELDS` is the new + eighth, and how it was missing is the finding rather than a footnote: it is + the only one of the eight that pairs its keys as `[collection, kind]` tuples, + which neither existing extractor could read, so the site was skipped rather + than reported. Re-adding `policies` — or any other key the schema does not + declare — to **either** registrar now fails the gate with the site named. + - A new `tupleFirstItems` extractor reads that shape, with a self-test case + (13 assertions, up from 12) covering the comment/nesting cases the flat + string-array extractor already pins. + + The mirror-image half of the same measurement is **carried, not shipped**: + `capabilities` is a declared top-level collection that `SECURITY_FIELDS` + registers and the door's map does not, making `AppPlugin` its sole registrar on + an artifact boot. Adding it to the door changes what an artifact boot registers, + so it is measured and handed to the route-ownership decision (#12892) instead of + being smuggled in here. The new waiver row records the asymmetry in place. +- fe3d74f: fix(runtime): a refused `POST /automation/:name/toggle` is told what it attempted (#11666) + + The enablement door refuses in its own words now. A caller without + `manage_metadata` that hit `POST /api/v1/automation/:name/toggle` was answered + with the refusal the three definition writes share: + + ```text + before: Authoring automation flows requires the `manage_metadata` capability. + after: Enabling or disabling an automation flow requires the `manage_metadata` capability. + ``` + + They were disabling a flow, not authoring one. The sentence was accurate about + the policy — #10243's ruling classified toggle into the `manage_metadata` + authoring write set — and it named a verb the caller did not use. + + ⛔ **Copy only; no policy moved.** The accept set is bit-identical: the same + callers are refused on the same four routes, `POST /` / `PUT /:name` / + `DELETE /:name` keep the shared sentence they read correctly with, and the + envelope is untouched — `PERMISSION_DENIED` / **403** on every arm, as #11660's + pins and the ADR-0112 vocabulary assert. Nothing becomes newly accepted or + newly rejected. + + Shaped on this domain's own precedent (`SCREEN_READ_DENY_MESSAGE` beside + `RUN_READ_DENY_MESSAGE`, #7968): a second constant for a second question, + rather than a reworded shared one. Rewording the shared sentence to cover both + was considered and declined — it would degrade the message for the three + definition writes in order to fix one arm. Both sentences still satisfy #7450: + each names the capability that would admit any caller, and nothing about this + one. + + A client branching on the human-readable prose of a 403 (rather than on + `error.code`) is the only thing that can notice. +- 23843d3: docs(runtime): `cluster: false` does not mean `service-cluster` contributes nothing — say so where it is read (#12679) + + No behaviour change. This ships a **statement**, at the three places an + engineer or an operator forms their belief about what the `cluster` flag turns + off, plus the pin that keeps the statement true. + + Since the authorization-cache substrate (#11968), `Runtime` registers + `AuthzClusterBridgePlugin` **unconditionally** — `cluster: false` included — so + that flag stopped meaning "this package contributes nothing" without anything + saying so. That is the declared-not-equal-actual shape: someone reading + `cluster: false` in a config reasonably concludes `@objectstack/service-cluster` + does not participate, and is wrong. #12679 weighed moving the posture statement + out to `core` and **ruled against it** (option A): the registration is correct + where it is, because an authorization grants cache running with no invalidation + bus at all is the *loudest* case the posture check has, and skipping the check + under `cluster: false` would silence the platform precisely in the configuration + most worth announcing. The cost of keeping it is a flag whose name overstates + what it turns off, and the ruling pays that cost in text rather than in a + cross-package move. + + - **`RuntimeConfig.cluster`'s docblock** now states what `false` actually skips + (the two *cluster* plugins) and names the one plugin that survives it, why it + survives, and that it stays inert on the shipped default — with + `OS_AUTHZ_GRANTS_CACHE_TTL_MS` at `0` it attaches nothing, publishes nothing + and logs nothing above `debug`. This text ships in the package's `.d.ts`, so + it reaches an integrator in their editor at the moment they write the flag. + - **`content/docs/kernel/cluster.mdx` §8** carries the same statement + author-facing, replacing "skip registration entirely" — which was accurate + before #11968 and is not any more. + - **The `cluster: false` pin** asserts the bridge **by name, before any count**. + It already named the bridge, but a `toHaveBeenCalledTimes(1)` ran first and + aborted the test, so dropping the registration reported `expected 1, received + 0` — arithmetic about a security-relevant statement. It now fails with the + plugin id and a message explaining the contract; the exact-set and count + assertions are kept, moved after it. + + Operators changing nothing see nothing change. The one observable difference + predates this changeset: under `cluster: false` the plugin *count* is 1 rather + than 0, which matters only if you assert on it. +- d2b2381: **Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). + + `saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. + + The maintainer ruled a **split**, and the two halves are not the same fix: + + - **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. + - **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. + + For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. + + The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. +- 67ceb9a: The dispatcher `/metadata` transport folds the URL segment before deciding + organization scope — `/metadata/translations/:name` no longer writes to a + different partition than `/metadata/translation/:name` + + Two maps that must agree did not. `protocol.saveMetaItem` folds the path + segment through `canonicalizeMetaRequestType` → `META_URL_TO_SINGULAR`, the + **complete** spelling map, for storage. The dispatcher handed the same string + **raw** to `organizationIdForMetaWrite`, whose `declaresOrgOverride` tolerates + only the manifest-collection spellings — incomplete by design. + + For the two URL-only spellings of `allowOrgOverride: true` types the two + answers diverged. `translation` has no manifest collection key at all; + `email_template`'s is the camelCase `emailTemplates`, so the snake_case plural + the registry derivation adds is URL-only too: + + ``` + PUT /metadata/translation/:name → org-scoped row (correct) + PUT /metadata/translations/:name → env-wide row (the defect) + PUT /metadata/email_template/:name → org-scoped row (correct) + PUT /metadata/email_templates/:name → env-wide row (the defect) + ``` + + Storage folded both spellings to the same canonical type, so the rows differed + in `organization_id` alone: one item in two partitions, addressed by spelling. + Measured end-to-end through the real dispatcher, protocol and repository — + writing an item under both spellings left **two** `sys_metadata` rows where + there should be one, and the env-wide one is shadowed by every read the + org-active author makes. Persisted, receipted 200, served by nothing. + + `GET /metadata/:type/:name/published` is the smaller second site of the same + class. After the layered overlay consult misses, the fallback reads the + code/package store, which is keyed by canonical type; handed the raw segment it + answered **404** under a recognised plural for an item the singular twin + answered **200** for. + + Both sites now fold through `canonicalMetaUrlType` at the boundary — the + correction the REST `/meta` doors already carry, and the one + `metadata-url-spelling.ts` mandates ("folding happens at the boundary and only + there; the layers below keep reading the single canonical singular"). ⛔ Not by + widening `declaresOrgOverride`: a predicate below the boundary consuming the + URL spelling contract is the repair that module's header forbids. + + Only the scope **argument** is folded. The request `type` stays the raw + segment, exactly as the REST doors leave it — the protocol boundary folds it + itself, and two pre-folds would hide a drift between them from the protocol's + own tests. A type the contract does not map (a plugin-registered kind such as + `webhook`) still reaches the store verbatim: the fold is a lookup, never a + spelling guesser. + + ⚠️ Whether real callers reach this transport with plural spellings has **not** + been measured. The REST transport was the measured, user-visible surface; this + one is corrected so the class is closed on both transports rather than one. +- e7dfb1d: fix(runtime): honour `datasource.external.validation.checkOnBoot` in the boot validation sweep (#13037) + + `checkOnBoot` has been declared on `DatasourceSchema` — with `.default(true)` — + since the `external.validation` block was written, and **nothing read it**. Its + two block-mates are read (`onMismatch` by `resolveOnMismatch()`, + `checkIntervalMs` by `scheduleDriftChecks()`), which is what made the gap legible + rather than a whole-block miss: `ExternalValidationPlugin.start` hooked + `kernel:ready` and called `runValidation(ctx)` with no condition on it. + + So an author who wrote `validation: { checkOnBoot: false }` and left `onMismatch` + at its default got the boot sweep anyway, and a measured mismatch threw + `ExternalSchemaMismatchError` and **aborted boot** — the exact outcome the key + reads as opting out of. The `.default(true)` made it worse than an ignored key: + the knob is materialized into every parse output, so a dead setting is + byte-identical to an honoured one in stored and serialized datasources, and + neither an author, an AI author, nor someone reading the metadata store could + tell which one they had. + + Maintainer ruling 2026-08-29 — ADR-0049 disposition **enforce, not remove**: + + - `checkOnBoot: false` ⇒ that datasource is skipped by the `kernel:ready` sweep. + No `onMismatch` policy is applied to its rows, so a measured mismatch on it can + no longer abort startup; its unreachable-remote rows raise no boot warning; and + its objects are not counted in the all-clear. The skip is logged once, naming + the datasources and stating that the verdict beside it covers the remaining + ones only. + - `checkOnBoot: true` or absent ⇒ today's behaviour, unchanged — a measured + mismatch still throws `ExternalSchemaMismatchError` and aborts boot under the + default `onMismatch: 'fail'`. + + The gate is **per datasource**, because the sweep is whole-farm and the key is + per-source: in one boot, an opted-out datasource does not suppress another + datasource's abort. Every uncertainty resolves towards running the check — an + absent key, an unparsed or legacy stored row, a managed datasource with no + `external` block, and a definition the metadata service could not read are all + validated, never inferred to have opted out. + + **Scope, pinned at the ruling: the boot step only.** `scheduleDriftChecks()` and + its `external.validation.checkIntervalMs` read point stay independent — a + datasource that opts out of the boot check keeps whatever background drift + checking it armed. The two keys answer different questions ("gate my startup on + this" versus "watch this while I run"), and both the code comment and a test hold + that boundary. + + Not a contract-face change: no schema, no key, and no accepted spelling moves. + `checkonboot` and `validateonboot` remain what they already were — entries in the + `strictObject` rejection table that refuse the misspelling and prescribe + `checkOnBoot` — so `checkOnBoot` is the single authorable spelling and the gate + has a single read point. +- 735f5c7: **Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). + + `ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. + + Now: + + - **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. + - **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. + - **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. +- dc75ba8: feat(spec,client): bind published response contracts for the 17 unbound client-SDK methods; retire the false `PackageRollbackResponseSchema` (#12038, ruling 1C · 2C · 3A · 4A · 5A) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` `api/PackageRollbackResponse` + plus the D3 semantic entry `package-rollback-response-retired` — where + `os migrate meta` users will look). + + FROM → TO: + + - `PackageRollbackResponseSchema` / `PackageRollbackResponse` / + `PackageRollbackResponseParsed` → `RollbackToPackageCommitResponseSchema` / + `RollbackToPackageCommitResponse` (`@objectstack/spec/api`). The retired + schema declared a VERSION rollback (`{ success, restoredVersion?, + message? }`) while the live `POST /packages/:id/rollback` route posts + `{ commitId }` and answers the ADR-0067 COMMIT rollback — + `{ success, revertedCommits: string[], failed: [{ commitId, error }] }`. + Read `revertedCommits` / `failed`; there is no `restoredVersion`. + - `PackageApiContracts.rollbackPackage` → *(removed)* — it bound the + wrong-operation schema to the exact live path. No route registration or + SDK generation ever consumed it (zero consumers measured across + objectstack, objectui and cloud; only its own unit test and the #11925 + compile-time guard, both updated in this PR). + + One-line fix: replace any import of `PackageRollbackResponse(Schema)` with + `RollbackToPackageCommitResponse(Schema)` and read `revertedCommits` / + `failed` instead of `restoredVersion`. `PackageRollbackRequestSchema` stays + published (ruled out of the retirement), bound to no route. + + The rest of the change is additive — the recorded five-part maintainer + ruling (2026-08-27) for the 17 client-SDK methods that had no published + response contract: + + - **12 describe-only transcriptions** into `@objectstack/spec/api`, each + from the return type its producer already declares inline (no wire byte + changes): `ListDraftsResponseSchema`, `GetMetaDiagnosticsResponseSchema`, + `FindReferencesToMetaResponseSchema`, `RollbackMetaItemResponseSchema`, + `DiffMetaItemResponseSchema`, `ResolvedBookSchema` (authored beside its + interfaces in `system/book.zod.ts`), `DiscardPackageDraftsResponseSchema`, + `ListPackageCommitsResponseSchema` (the `{ commits }` wrapper declared as + the handler's own), `RevertPackageCommitResponseSchema`, + `RollbackToPackageCommitResponseSchema`, + `ReassignOrphanedMetadataResponseSchema`, `DuplicatePackageResponseSchema`. + - **Ruling 1C**: `GetPublishedMetaItemResponseSchema` is deliberately opaque + (`z.unknown()`) — the route answers an arbitrary metadata item body, never + a union frozen against the type registry. + - **Ruling 2C**: `meta.migrateStored` stays UNBOUND, documented at its two + ledger rows and in the SDK — `StoredMigrationReport` lives in + `@objectstack/metadata-protocol`, and a second declaration would drift. + - **Ruling 4A**: `PackageExportManifestSchema` pins the four fixed keys + (`id`, `name`, `version`, `label?`) and stays honestly open for the + registry-derived plural keys. + - **Ruling 5A**: `PackagePublishResultSchema` and the `ResolvedBook` family + are re-exported into `@objectstack/spec/api` (the namespace the + route-ledger resolver searches) — never a second copy. + - The 18 boundable route-ledger rows in `@objectstack/runtime` and + `@objectstack/rest` now name their `responseSchema`, each stating which + surface's envelope it describes; every named schema carries conformance + coverage (the #3877 rule). + - The client SDK binds 16 of the 17 methods to the published payload types, + replaces four invented test mocks with producer-true shapes, and pins the + `unwrapResponse` mis-unwrap hazard so no bound payload can declare both a + boolean `success` and a `data` key. +- 9981f31: Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) + + `@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` + returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and + `os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: + `RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares + `z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this + key's vocabulary, and the disagreement survived because nothing ever executed + the schema — `RestServer` cast its config instead of parsing it. + + `StandaloneStackResult['api']` now declares, and the factory now emits, + `projectResolution: 'auto'`. + + **Behaviour on the routing path is unchanged, and that is measured, not assumed.** + Every reader that acts on this key is gated on `enableProjectScoping` first: + `RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` + mounts `[versionedBase]`, and the Dispatcher plugin's two + `enableProjectScoping && … === 'required'` guards short-circuit. With scoping off + the strategy really is moot for routing — which is why this migrates the value + rather than teaching the enum a fourth member. + + **One reader is not gated, and that is the user-visible fix.** `RestServer`'s + discovery handler copies `api.projectResolution` into + `discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares + that field as the same three-member enum. So `GET /api/v1` on every `os serve` + boot advertised a payload the platform's own schema rejects. Clients that + validate discovery — or switch on `scoping.resolution` — now receive a declared + value. + + Both halves are pinned rather than described: `merge-boot-config.test.ts` parses + the CLI's real boot block against `RestApiConfigSchema` and against the discovery + field's enum, and `standalone-stack.test.ts` parses the block the factory + actually returns. Each pin asserts the refusal of `'none'` alongside the + acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now + typed as `StandaloneStackResult['api']`, so it can no longer drift from the + producer without failing `tsc`. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [6747718] +- Updated dependencies [340c5e5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [32448d4] +- Updated dependencies [54e2d36] +- Updated dependencies [7ef0268] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [f93df4d] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [eae824e] +- Updated dependencies [178f90c] +- Updated dependencies [f6fa22c] +- Updated dependencies [56d3c7a] +- Updated dependencies [8a483b3] +- Updated dependencies [84de7e3] +- Updated dependencies [3bc2e38] +- Updated dependencies [7181101] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [3d79144] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [f9ffd01] +- Updated dependencies [376c70f] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [34d3011] +- Updated dependencies [aa3f9ba] +- Updated dependencies [9d3c04d] +- Updated dependencies [d29e42f] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [3f42920] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [ebcc34e] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [78f65ef] +- Updated dependencies [901355c] +- Updated dependencies [107bb4b] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [30928a6] +- Updated dependencies [de47336] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e170b0a] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [33b52fe] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [457ff75] +- Updated dependencies [ece4dad] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [c5b9ccc] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [d81838c] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [431d2fb] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [31bb2e7] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [6c6157a] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [db39dfc] +- Updated dependencies [3c0f3ea] +- Updated dependencies [26deb31] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [d48929e] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [f4e741b] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [3519f8d] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [8965398] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [1c66fe4] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [18b53ac] +- Updated dependencies [bd0c5cc] +- Updated dependencies [e1d773e] +- Updated dependencies [9a884c6] +- Updated dependencies [1cba33f] +- Updated dependencies [da1126a] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [cd13488] +- Updated dependencies [df1c75c] +- Updated dependencies [1e4d2eb] +- Updated dependencies [b853cf3] +- Updated dependencies [4af6c44] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [3956069] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [d028b37] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [1246b4c] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [4d5b4f8] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-cluster@17.3.0 + - @objectstack/metadata-protocol@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/driver-sqlite-wasm@17.3.0 + - @objectstack/formula@17.3.0 + - @objectstack/service-i18n@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 48c027a06a..3d5b89fda7 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.2.0", + "version": "17.3.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 0c1ffb8678..76034451fd 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,139 @@ # @objectstack/sdui-parser +## 17.3.0 + +### Minor Changes + +- 2a5c1cd: html tier: a braced attribute value that is not strict JSON now draws an `inert-expression` warning instead of vanishing silently + + `interpretBrace` materializes strict-JSON values only; anything else — the + single-quoted array every JSX author writes (`columns={['name','amount']}`), + unquoted object keys, any JS expression — compiles to the deferred `{ $expr }` + marker, and nothing downstream evaluates that marker: this tier parses, never + executes (ADR-0080), and no renderer consumes `$expr`. The value reached the + renderer as an opaque object, defensive non-array/non-object reads degraded it + to "not declared", and the author's binding vanished with zero diagnostics + anywhere — a production page's `list-view` rendered its row count and toolbar + with no data columns, through eight `columns` spellings (objectui#6598). That + is ADR-0078's prohibited parsed-but-silently-inert state. + + `validateTree` now emits a warning-severity `inert-expression` diagnostic when a + declared input's value is the `$expr` marker, with the fix in the message: write + the value as JSON (double-quoted strings and keys). + + This is the lockstep port of objectui PR #6613 into this repo's hoisted copy of + the parser. There are two copies, and the invariant is that both agree on the + accepted grammar **and** on diagnostic codes — if they drift, the save gate and + the renderer speak different dialects, and a page can save clean and render + inert. The emitted diagnostic is byte-equal to objectui's. + + Warning, not error, per the objectui#5709 posture for inert authored keys: this + reports an **already**-inert state, so the accept/reject set does not move. + Pages that compiled before still compile, and a warning is non-gating on every + consuming surface in this repo (`runtime-gate` files warnings as advisories, not + as write refusals; `os lint` exits non-zero on error-severity findings only). + The silence is what changed. Escalating the severity, widening the accepted + literal grammar (single-quoted strings, unquoted keys), and wiring the registry + manifest into `validate-jsx-pages` — without which this warning is recorded in + compile output but displayed by no production surface — are separate decisions + tracked on objectui#6614 and its follow-ups. +- 8beb3de: html tier: a dashboard widget `options` key that reaches no renderer now draws an `unconsumed-widget-option` warning naming the consumed set + + `@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()` + ("declared query keys + open renderer extras"), so ANY key parses, validates + and lints cleanly — including one no renderer reads. That is how a dashboard + shipped `options: { invert: true }` on a gauge with a comment saying what it + was believed to do and rendered the un-inverted measure with no diagnostic + anywhere (objectui#5709). The 2026-08-23 maintainer ruling on that card: open + extras stay open — they just stop being **silent**. A key that reaches no + renderer draws a **warning** naming the consumed set. + + objectui's copy of this parser has emitted that warning since the ruling + landed; this repo's hoisted copy emitted nothing, so the same authored page + produced a diagnostic on one surface and silence on the other — the dialect + split the two copies' invariant forbids (objectstack#12719 — both copies agree + on the accepted grammar **and** on diagnostic codes). `validateTree` now ends + its known-component branch with `checkDashboardWidgetOptions(node)`, and the + new module is a byte-equal port of objectui's save for one token (the emitted + `code` is spelled as an inline literal rather than through the exported + constant, so this repo's ADR-0112 vocabulary gate can classify it — called out + at the site, and pinned equal to the constant by test), so the emitted `code`, + `severity`, `message` and census scope are identical. + + The warning is scoped to the only spec-legal render path: a `dashboard` / + `dashboard-grid` host, a widget with a `dataset`, not in the legacy + `component` format, and not carrying the spec's own + `suppressWarnings: ['unconsumed-widget-option']` escape hatch. The consumed set + is the five keys `DashboardWidgetOptionsSchema` declares (`dateGranularity`, + `sortBy`, `sortOrder`, `limit`, `stageOrder`) plus `description`, the metric + sub-caption channel `translateDashboard` writes into `options`. + + New exports for third-party manifest consumers: `checkDashboardWidgetOptions`, + `CONSUMED_WIDGET_OPTION_KEYS`, `DASHBOARD_WIDGET_HOST_TYPES` and + `UNCONSUMED_WIDGET_OPTION` (the diagnostic code, which is also the id + `suppressWarnings` suppresses). + + Unlike the union-arm port that preceded it, this change is **additive**: it + reports an already-inert state and emits `warning` only, so what this copy + accepts and rejects is exactly where it stood — pinned by a dedicated test. + Today it is latent in the production gate anyway: this repo resolves no + `sdui.manifest.json`, so `validateJsxPages` runs parse-only and `validateTree` + is not reached from it. Wiring that manifest (the second gap recorded on + objectstack#12719, still unowned) is what makes this author-visible, and this + port lands ahead of that wiring deliberately. +- 4a9f461: html tier: a union-typed manifest input is now coarse-type-checked over every declared arm instead of drawing no diagnostic at all + + `ManifestInput.type` now carries ONE coarse kind, or an ARRAY of kinds when the + key's contract is a union (objectui#3832). Before this change, this copy's + `checkType` was the older single-arm `switch (input.type)`: a manifest input + declaring a union fell through `default: return null` and drew **no diagnostic + at all** — silence indistinguishable from a value that validated cleanly — + while objectui's copy checked every arm. The same authored page produced + diagnostics on one surface and none on the other: the dialect split the two + parser copies' invariant forbids (objectstack#12719 — both copies agree on the + accepted grammar **and** on diagnostic codes). + + `validateTree`'s coarse check now clears a prop when **any** declared arm + accepts the value, and when **no** arm accepts it emits **one** `type-mismatch` + diagnostic naming every arm — at `error` severity when an `enum` arm is + present (an enum's closed list is the one fact this layer can be certain + about), `warning` otherwise. A single-arm input produces the byte-identical + diagnostic it always did, `invalid-enum` included. `generateDts` emits a + TypeScript union for a union declaration, and `manifestFromConfigs` + canonicalizes union declarations through the new `input-type.ts` module + (`inputTypeArms`, `canonicalizeInputType`, `MANIFEST_INPUT_TYPES` — all + exported, so third-party manifest consumers read arms through the same + accessor the gate does). + + This is the lockstep port of the objectui#3832 ruling into this repo's hoisted + copy of the parser — the ported check is byte-equal to objectui's. It changes + what the save gate accepts and rejects for union-typed inputs: a value fitting + no arm of an enum-carrying union now draws an `error` where it previously drew + nothing. Today that change is latent in the production gate — this repo + resolves no `sdui.manifest.json`, so `validateJsxPages` runs parse-only; wiring + the manifest (the second gap recorded on objectstack#12719) is what makes it + author-visible, and this port lands ahead of that wiring deliberately. + +### Patch Changes + +- 34f60b7: The JSX-source parser no longer deletes the space that separates a text run + from an adjacent sibling element. `parseChildren` collapsed each text run's + whitespace to a single space (correct — that is HTML's own whitespace model) + and then `.trim()`ed it (not correct — HTML collapses a whitespace run to one + space, it does not delete it), so `A x page` compiled to + `['A', {strong}, 'page']` and the words ran together wherever that tree is + rendered. + + The rule now applied: collapse the run, then keep one leading space when a + sibling precedes it and one trailing space when a sibling element follows it; + at the parent's own start/end the edge space is still dropped, so + `

    hi

    ` still compiles to `['hi']`. It is deliberately mechanical — it + invents no block/inline taxonomy for a schema tree that has none. Its one + bounded cost is that a whitespace-only run between two siblings survives as a + single space, so a pretty-printed `
      ` gains one `' '` child per inter-item + gap; the tests pin that bound. This matches the rule the downstream copy of + this parser already applies, so the two agree on the tree they produce. + ## 17.2.0 ## 17.1.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 59e6fab6fd..734ec9c716 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.2.0", + "version": "17.3.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 e14d94abc5..c20fe24643 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,308 @@ # Changelog — @objectstack/service-analytics +## 17.3.0 + +### Minor Changes + +- 74cee59: Resolve `{current_user_id}` (and every other filter placeholder) on the direct analytics query path, at parity with the list path and the dashboard dataset path. + + What changes for an app author: a widget or report whose filter says `owner: '{current_user_id}'` used to render `0` for every viewer whenever the query reached the SQL strategy — the literal text was bound into the `WHERE` and matched no row, silently. Now the same filter expression means the same thing on every surface: `AnalyticsService.query` and `generateSql` expand `where`, `timeDimensions[].dateRange`, and a registered dataset's own filter / measure filters against the requesting user before any strategy compiles, so each viewer gets their own rows. A placeholder that cannot be resolved — an unknown spelling, or `{current_user_id}` on an unauthenticated request — now refuses loudly with `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` (HTTP 400) instead of charting a plausible zero. + + This also closes a gap on the dashboard dataset door: the dataset-scope channel used to hand strategies the registry's unresolved filter copy, which was ANDed in beside the resolved one (`owner = $viewer AND owner = '{current_user_id}'`) and selected nothing. +- 399ecad: `ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter. + + Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf. + + Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says. + +### Patch Changes + +- c8be110: refactor(service-analytics): derive the analytics auto-bridge's engine view from the declared contracts (#11833) + + `plugin.ts` named the data engine through a consumer-local structural + `DataEngineLike` — the second of the two sites #11833 records, after the + datasource half that landed as PR #12011. It is now derived from the declared + contracts: `IDataEngine.aggregate` / `execute?` / + `resolveEffectiveDatasource?` / `getDriverForObject?` and + `IObjectQLEngine.getObject`. Optionality is preserved exactly — `aggregate` + required, everything else `Partial<>` — because these probes are the plugin's + graceful-degradation seam. + + **Why this is `patch` and not a type-only no-op.** Four of the five members + substitute with no behaviour change. The fifth does not: the deleted structural + type declared `aggregations[].function` as `string`, while the contract + declares the six-value `AggregationFunction`. The bridge therefore forwarded + whatever method string reached it. That forward is now parsed with the spec's + own enum, so a method the engine contract does not declare is refused at the + bridge — loudly, naming the aggregation and the legal vocabulary — instead of + reaching the engine, where `driver-sql` blamed a `function` key the author + never wrote and the in-memory evaluator answered `null` for every bucket under + the author's own measure name. + + No authored analytics can trigger the new refusal: the one reachable producer + of a non-aggregate method — a custom-SQL measure (`AggregationMetricType` + `number` / `string` / `boolean`) — is already refused earlier, caller-facing, + by `ObjectQLStrategy.resolveMeasureAggregation` (#12209). What is left is host + drift (a cube object registered without meeting `CubeSchema`), which is why + the new refusal is a bare `Error` in the undeclared-500 tier rather than an + ADR-0112 400 that would blame the caller for something they did not write. +- 017130a: The ObjectQL analytics strategy now refuses a custom-SQL measure (`AggregationMetricType` `number` / `string` / `boolean`) with a loud `400 INVALID_FIELD` naming the measure and its metric type, instead of forwarding the raw SQL expression into `engine.aggregate` — where `driver-sql` rejected it blaming a `function` key the author never wrote, and the in-memory evaluator silently answered `null` for every bucket under the measure's own name. + + What stops being served, and for whom: on deployments whose driver has no native SQL capability (the ObjectQL aggregate path — e.g. Mongo or in-memory), a query or dataset widget selecting a custom-SQL measure now answers a 400 that says to use an aggregate measure (count/sum/avg/min/max/count_distinct) or run the cube on a native-SQL driver. Those queries previously "succeeded" with a per-bucket `null` (or a mis-attributed driver error), never with a correct number. Native-SQL driver behaviour is unchanged: custom-SQL measures still run there, emitted verbatim. +- 466b389: **Fix:** `/api/v1/analytics/query` on the ObjectQL door (MongoDB, the memory driver, or any deployment whose driver reports `objectqlAggregate` but not `nativeSql`) now honours a measure's own scoped `filter` — `won_count` and `won_amount`-style conditional measures answer the same numbers the dashboard door and the native-SQL door already did (#10413 phase 2). + + `ObjectQLStrategy.execute` lowers each measure's `filter` into the ONE aggregation it belongs to, via the per-aggregation `filter` field #10576 added to `engine.aggregate`'s contract (SQL `FILTER (WHERE …)` semantics) — not into the whole-call filter, which would have narrowed every measure (a fix shaped that way would make a conditional measure right while making every unconditional sibling measure in the same query wrong). An aggregation with no measure filter is unchanged and keeps the native-pushdown-eligible shape. + + `ObjectQLStrategy.generateSql` (the `/analytics/sql` echo) renders the same conditional aggregate — `COUNT(CASE WHEN … THEN … END)`-style — so the preview stays an honest description of what `execute()` now actually runs, matching the native-SQL strategy's existing echo for the same class of measure. + + Phase 1 (PR #10758) already ANDed a dataset's definition-level `filter` into the whole-call filter on this door; this closes the remaining half of the two-door disagreement #10413 reported. `NativeSQLStrategy` (#10298 / PR #10411) is unaffected by this change. +- a3c4215: fix(service-analytics): wire the `typecheck` script so turbo stops silently no-opping the gate, and clear the 10 type errors it was hiding (#12939) + + `packages/services/service-analytics/package.json` declared only `build` and + `test`. Root `typecheck` is `turbo run typecheck`, which **no-ops a package + that has no such script and reports success** — so no tsc read this package's + `src/` from the typecheck lane at all. `build` is tsup (esbuild; the DTS pass + processes declarations only) and `test` is vitest (esbuild transform), and + neither type-checks. The package was reached only by the `check:type-check-debt` + ratchet, which asserts the error count does not *grow* — never that it is zero. + + Adding the one-line script (mirroring its sibling `service-settings`, repaired + the same way in #7925) makes the task real. The tests are already inside the + program — the package `tsconfig.json` includes `src` and the tests live in + `src/__tests__/**` — so `tsc --noEmit --listFiles` lists **83 of the 83** + `*.test.ts` files on disk. The new gate reads the tests, not just the source. + + All 10 errors were stale tests, not source defects; no non-test source file + changed. Nothing was silenced: no `any` added, no `@ts-expect-error`, no + `@ts-nocheck`, `strict` untouched, and the tsconfig `include`/`exclude` are + byte-identical — excluding the tests would have converted a missing gate into + a lying one. + + - `__tests__/measure-source-field-gate.test.ts` (7 x TS2339). `promise.catch(fn)` + does not drop the resolved branch from the type, so + `service.query(...).catch((e) => e as Error)` was `AnalyticsResult | Error` + and every `err.message` / `err.field` / `err.member` / `err.param` read was a + property access on `AnalyticsResult`. A local `refusalOf()` helper narrows it + once via `then`; as a bonus the resolved branch now fails by + name instead of surfacing later as `expect(undefined).toMatch(...)`. + - `__tests__/objectql-timedimension-projection.test.ts` (2 x TS7053). The + `TABLE` fixture was inferred as `{ id: number; due_date: string; priority: + string }[]` and the aggregate stand-in indexes it by a computed `string` key. + Annotated as the `Row` (`Record`) the file already declares. + - `__tests__/analytics-service.test.ts` (1 x TS6133). An unused + `AnalyticsDriverCapabilities` type import. The capability literals in this + file are inline `ctx` objects checked contextually at each `canHandle` call + site, so the import added no coverage and is removed. + + `service-analytics` graduates out of the `check:type-check-coverage` DEBT + ledger: 65/78 -> 66/78 workspace packages type-checked, 382 -> 372 frozen raw + errors, 13 -> 12 ledger entries. +- d028b37: fix(spec): `StrategyContext.executeAggregate` `aggregations[].method` narrows from `string` to `AggregationFunction` (#12776) + + + + **BREAKING** accept-set narrowing on a published contract, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). + + Two spec-declared surfaces described the same slot and disagreed about its + type: `IDataEngine.aggregate`'s `aggregations[].function` is the closed + six-value `AggregationFunction` enum, while the analytics strategy contract's + `StrategyContext.executeAggregate` declared the same value as + `aggregations[].method: string`. The analytics bridge renames one to the + other, so nothing on the analytics side of that seam was compile-checked + against the engine's vocabulary — a strategy author (very often an AI) got + no compile-time help and hit the bridge's runtime refusal instead. + + FROM → TO: + + - `aggregations[].method: string` → + `aggregations[].method: AggregationFunction` + (`'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`, the spec's + own enum from `@objectstack/spec/data`). One slot, one declaration. + + Who breaks at compile time on upgrade: + + - external CALLERS of `StrategyContext.executeAggregate` that fill `method` + with a value typed `string` (or a literal outside the six) — the values the + bridge already refused at runtime (#11833) now fail `tsc`. + - external IMPLEMENTORS of `StrategyContext` stay source-compatible: a + handler that accepts `method: string` accepts a superset and remains + assignable to the narrowed member. + + The bridge's runtime parse-and-refuse (#11833) stays as defence in depth. + In-repo, `ObjectQLStrategy`'s aggregation locals now carry the enum + end-to-end (`@objectstack/service-analytics`, runtime behaviour unchanged — + the census measured every reachable producer already emitting enum-legal + values only). +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index b6bda74cd4..771fd73622 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.2.0", + "version": "17.3.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 7f149225f9..6aea4c2bd2 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,581 @@ # @objectstack/service-automation +## 17.3.0 + +### Minor Changes + +- 0d7b1f3: fix(service-automation): one renderer for the contested-flow phrase, and the two spellings it had drifted into (#12563) + + `minor`, not `patch`, and not empty: this adds a new export + (`renderFlowContender`) to a published package's public API, and it changes + **shipped operator-facing log text**. Both are real changes a consumer can + observe. + + ## What changed + + One event — a flow name claimed by more than one definition — was described to + an operator in three places, each with its own private `const describe` beside + the log call: `flow-precedence.ts`'s precedence warning, `plugin.ts`'s bootstrap + audit, and (in `@objectstack/cli`) the startup banner. Nothing held them equal, + and two axes had already drifted: + + - **Quoting.** `flow-precedence.ts` rendered `package "crm"`; the other two + rendered `package 'crm'`. + - **Absent package id.** The two engine copies interpolated a bare `undefined` + into the sentence; the CLI copy rendered a real fallback. + + The two copies in this package are now one exported renderer. The choice on + each axis was measured, not voted: + + - **Single quotes**, measured against this package rather than across the three + copies: of the interpolated identifiers in operator prose under + `service-automation/src`, 203 are single-quoted and 3 double-quoted — one of + those 3 being this phrase. The sentence already single-quotes the flow name + beside it. + - **A named fallback** (`a code-shipped package (id unknown)`) instead of + `package 'undefined'`. This package's own callers cannot reach that branch + today, because `isCodeArtifactBody` is false on a falsy `_packageId` — but + that is a property of today's callers, not of an exported function. + + ## Log text a consumer may be matching on + + `[Automation] Flow name collision: …` (the precedence warning) now renders a + packaged contender as `package 'crm'` rather than `package "crm"`. + `plugin.ts`'s bootstrap `[Automation] flow '' is claimed by …` warning is + byte-identical to before for every input its callers can produce; only its + unreachable absent-id branch changed. + + ## Why the CLI still renders its own + + `@objectstack/cli` deliberately keeps its own spelling and takes no value + import of this package for the banner: its engine reads are structural and + feature-detected so a host on an older automation package still boots. The + third copy is held equal by a test-only agreement pin + (`packages/cli/src/utils/format.flow-contender-agreement.test.ts`) that asserts + the banner line through this renderer, so it goes red in both directions. +- 8155855: Flow value expressions (`create_record`/`update_record` `config.fields`, `assignment` `config.assignments`) now support a small numeric function table — `round`, `floor`, `ceil`, `abs`, `min`, `max` — with every name and semantic mirrored 1:1 from the `@objectstack/formula` CEL stdlib (no second dialect: `round` is integer-only exactly like CEL's; for N-decimal rounding write `round(x * 100) / 100`, the same pattern CEL authors use). A flow can finally write a computed money value that satisfies its field's declared `scale` (`{round(amount * (1 - discount / 100) * 100) / 100}` → a `scale: 2` currency field). + + Loud diagnostic in the same stroke: an identifier in call position that is not a supported function — `ROUND(...)`, `Math.round(...)`, `(x).toFixed(2)`, or the next name anyone invents — now fails the node with a named `FlowExpressionFunctionError` (guard-marked, so a `fault` edge cannot swallow it) instead of being silently rewritten to `null` and writing the field as `undefined`. Non-call template resolution is unchanged: unresolved plain tokens still become `null`/empty, and `NOW()`/`TODAY()` whole-token macros behave exactly as before. +- 1524927: Packaged flows can be switched off durably, and the process-local off-switch is retired + + Disabling a packaged flow now writes an install-level row to the + `sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a + process-local map. The engine consults that ledger at the `execute()` seam — + the one seam every entry path crosses (record-change, schedule, time-relative, + api, subflow) — and refuses a disabled flow there with the existing + `FLOW_DISABLED` code; the ledger case is distinguished by the message, so no + new error code joins the ADR-0112 ledger. An install-level disable also unbinds + the flow's trigger, and re-enabling rebinds it. Absence of a row means the + packaged default, active, so a deployment that never flips anything behaves + exactly as before. + + This retires the mechanism behind #10243 rather than refining it. The old + `flowEnabled` map was not a row, so no organization wall scoped it: on a walled + multi-organization deployment a tenant org owner could switch a shipped flow + off environment-wide and an unrelated tenant read it off. The durable row + replaces it, and because a durable install-wide switch writable by tenants + would be that leak with persistence, the write is now authority-gated: + `POST /automation/:name/toggle` requires the platform operator in the `group` + and `isolated` postures, while the `single` posture — where install-level and + org-level are the same scope — is unchanged for the org admin who already holds + `manage_metadata`. The refusal names the posture and points at the clone path. + + Disabling a flow that packaged flows still call as a subflow is refused, and + the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks + mid-run at its subflow node with an inexplicable late failure. The check is a + definition scan at disable time over both `subflow` and `map` nodes; no + reference index is built. Enabling is never guarded. + + One behaviour change worth calling out: a disable now survives + unregister-and-re-register, which is what a package upgrade, a Studio publish + and the boot pull all do. ADR-0126 §6 requires it — the ledger records the + customer's choice, and no upgrade un-makes a choice — but it is the opposite of + what the retired in-process map did, where any re-registration silently + re-armed the flow. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. + +### Patch Changes + +- 8bb05ea: fix(service-automation): the documented broken-sweep predicate is a first FILTER, not the detector (#12685) + + `patch`, and not empty: `sys_automation_run`'s field descriptions are shipped, + translated, operator-facing text — they are what an admin reads in Setup while + wiring an alert they will then trust for months. No counter, no schema and no + engine behaviour changes here; the run summary measured by #4354 is correct and + untouched. + + ## The wrong claim + + `acted_count` advertised `selected_count > 0 AND acted_count = 0 AND + unmeasured_count = 0` as *the* broken-sweep signal, unqualified. Measured A/B on + one graph pair through the real engine — a healthy idempotent sweep (re-select + the same records, gate each one on "was this already handled") and a dead gate + (#4347's shape, the gate sitting in front of the lookup) — **both** report + `selected > 0, acted 0, unmeasured 0`. The predicate cannot make the one + distinction it was advertised to make. + + "Over N consecutive runs" does not rescue it either: the healthy steady state + trips it on *every* run for as long as the outstanding work stands, so it is + persistent rather than transient. Consecutiveness filters flapping, which is a + different failure. + + Why a wrong sentence here is worse than a wrong sentence elsewhere: a detector + that fires during normal operation gets muted, and a muted broken-sweep detector + is the same silence #4347 produced — with the added cost that it now *looks* + monitored. + + ## What the descriptions say now + + - `acted_count` states the predicate as the **first filter** and names the + discriminator: a healthy skip is accounted for by a read the run performed + (the lookup the gate depends on shows `runs > 0` and `selected > 0` in + `summary_json.nodes[]`), while a dead gate skips just as often with nothing + behind it (`runs: 0`, or `selected: 0`). + - `skipped_count` points at the same fold — `gates[]` names which edge closed + and how often, `nodes[]` says whether the lookup behind it found anything. + - `unmeasured_count` keeps its own point (why the third clause exists) and now + calls the query a filter rather than an alert. + + The discriminating data was already shipped by #4354; nothing new is measured + and no detector is implemented in the platform. `run-summary.test.ts` pins the + pair as executable evidence: both shapes match the filter, and the per-node fold + separates them. `content/docs/automation/flows.mdx` carries the same correction + with the measured table and the two authoring shapes that make a sweep's signal + quiet in its healthy steady state. +- fa5d137: feat(devx,datasource,automation): published `src/**` may only import workspace packages it declares (#10062) + + A package's non-test `src/**` was free to import any workspace package, + declared or not, and nothing checked it. The class was filed with one member + and a mitigation — the import was type-only, so nothing reached the emitted + JavaScript and rollup-plugin-dts inlined the declaration rather than naming an + unresolvable module. It grew to four members with no signal, and one of them + killed the mitigation: `service-automation/src/flow-precedence.ts` **value** + imports from `@objectstack/objectql`, which it does not declare, and because + the shared tsup config externalises only `dependencies`/`peerDependencies`, the + bundler answered by inlining objectql's implementation into + `service-automation/dist/index.js` — a second copy of another package's code, + kept correct by build configuration alone. + + `pnpm check:undeclared-dep-imports` is the gate, and the per-member fixes here + are decided one at a time rather than by a uniform policy — declaring makes a + coupling real and installable, routing it away removes it, and the two are not + interchangeable: + + * **`@objectstack/service-datasource`** now declares `@objectstack/driver-sql` + and `@objectstack/driver-memory` as **dependencies**. Both are loaded through + an *unguarded* `await import(...)` on the postgres, mysql, sqlite and memory + arms, so a consumer reaching one of those paths needed a package it was never + told to install, and would have met `ERR_MODULE_NOT_FOUND` rather than a + diagnosis. The three *guarded* driver arms — `@objectstack/driver-sqlite-wasm`, + `@objectstack/driver-mongodb`, `@objectstack/driver-turso` — are deliberately + left undeclared: each load sits in a `try`/`catch` that answers an absent + package with the fault, the consequence and the install command, and each + rides as an optional install. Declaring them would install them (turso drags + `@libsql/client`'s native bindings) and, measured on this branch, takes a live + assertion out of the tree: `default-datasource-driver-factory.test.ts` reaches + the missing-package arm with no stub precisely because the package does not + resolve from here. + * **`@objectstack/metadata-core`** now owns the ADR-0029 D9.6 provenance pair, + `isCodeArtifactBody` and `isTenantAuthored`, sunk out of + `@objectstack/objectql`'s registry by the same criterion as the write-verb + dispatch predicates and the audit governance table beside them: a second layer + needs the answer and the reverse import would either close a cycle or make the + consumer depend on the whole data engine for one predicate. `objectql` + re-exports `isCodeArtifactBody` from its original path, so its public API is + unchanged; `service-automation` imports it from `metadata-core`, which it + already declared, and its bundle no longer carries a copy of objectql's code. + + Two members stay recorded rather than remediated, because the tree already + carries the decision not to declare them together with its reason + (`@objectstack/runtime` → `@objectstack/driver-turso`, whose bare `import()` is + a host-replaceable default thunk under #6268; `@objectstack/rest` → + `@objectstack/objectql`, whose absence must degrade to `501 NOT_IMPLEMENTED` + rather than fail module load). Their ledger rows carry mechanical evidence and + go red the moment that evidence stops holding — in particular, a `type-only` + row reds on the day its import becomes a value import, which is exactly the + transition nothing caught the first time. +- e577445: The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient + + `NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` + path resolves `(name, locale)` with **one** locale for the whole notification. + The same retired promise survived outside the spec file, in the places an app + author is most likely to read it: + + - `service-automation/src/builtin/notify-node.ts` — the `template` field's + `configSchema` description, i.e. the text rendered in the **Studio form** the + author fills in. It said the row is "resolved by (name, recipient locale) at + delivery time and rendered per recipient". + - `content/docs/automation/email-templates.mdx` — the only site that stated the + conclusion outright rather than merely licensing it: "so one node mails each + person in their own language". + - `service-messaging/src/messaging-service-plugin.ts` — the channel-registration + log line, which advertised "resolve sys_email_template per recipient locale". + - Two internal comments in `notify-node.ts` and one in its test, describing the + payload the outbox snapshots as carrying a per-recipient-locale resolution. + + None of that is what the delivery path does. `payload.locale` is interpolated + **once, before fan-out**, so it is a single value for the whole notification, and + its fallback is the deployment default (`II18nService.getDefaultLocale()`). The + platform has no per-user locale to read — `sys_user` carries no locale column, + and request-scoped locale does not exist at async delivery time — so recipients + whose personal languages differ all receive the same template row. A per-user + locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers + in as an override at that same seam when it lands; the corrected wording dates + the deferral so it reads as a decision with provenance rather than an oversight. + + The gap was worth correcting because the wording licensed exactly one action — + convert `notify` nodes on the belief that non-English recipients get non-English + mail — and that action is a **net regression**: `TEMPLATE_*` failures classify + `permanent` and dead-letter, and the inbox channel starts requiring an email + service with `renderTemplate()` where inline text needed none. + + Text only: no schema accepts or refuses anything it did not before, no delivery + behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` + asserts the form description names `payload.locale` and the deployment default + and refuses a bare "recipient locale", so a later edit cannot quietly restore the + promise. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- ffbb7a1: Stamp `organization_id` on flow-produced notifications and on `markRead` + receipts, so the notification family stops writing org-less rows + + An application project's read-only inventory found `sys_inbox_message`, + `sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` + carrying `organization_id = NULL` on **100%** of their rows — existing rows and + same-day new ones alike, while `sys_approval_request` in the same database + carried an organization on every row. Ruled a gap, not a design choice. + + Everything below the messaging ingress was already threaded: `emit()` stamps the + `sys_notification` event, the inbox channel stamps `sys_inbox_message` and its + `delivered` receipt, and the outbox carries the value onto + `sys_notification_delivery`. Each of them reads `EmitInput.organizationId` — + and the `notify` flow node, the dominant producer, never supplied it. Its local + structural mirror of `emit()` did not even declare the field, so the value could + not have been passed. One missing argument, four tables at 100% null. + + The node now threads the organization from the run's own acting context + (`AutomationContext.tenantId`), the same source the `collab.mention` producer in + `@objectstack/plugin-audit` already uses, so the two notification producers agree + about whose organization a notification carries. + + A second producer of the same table is fixed alongside it: the `read` receipt + `markRead` inserts — written when a user reads a notification whose delivered + receipt never landed — named no organization at all. It now carries the + organization of the `sys_notification` row it is about. + + There is deliberately **no fallback limb** in either producer: not "the current + organization", not the install's first organization, not the recipient's first + membership. A run with no organization in scope still emits and still writes its + rows, and the `notify` node warns audibly naming the topic and the consequence. + A wrong `organization_id` is worse than a null — a null is visibly missing, + while a wrong value is silently authoritative to every report, export and + cleanup script that filters by organization. + + Forward-stamping only. Existing rows are not backfilled and no migration ships. +- aa0688a: Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name + + A runtime-authored flow that reused a packaged flow's name silently replaced it, + and which of the two ended up armed depended on registration order. The metadata + registry keys items `packageId:name` and deliberately coexists both (ADR-0048 + §3.4), `listItems('flow')` returns both with no precedence, and the automation + engine keys flows by bare name — so the boot pull registered both under one key + and Map iteration order picked the survivor. Measured: registering the package + first armed the runtime flow, registering the runtime row first armed the + packaged flow, with no warning and no way to tell which had won. + + The boot pull now collapses same-named definitions before anything is armed, + applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the + runtime/DB overlay wins over the packaged artifact, which is the sanctioned + override path. Two packages shipping one bare name resolve by package id, so + boot order no longer decides anything. + + Collisions are no longer silent. The pull warns once per colliding name — naming + the name, every contender, and which one is armed — and repeats it at bootstrap + beside the other automation audits. `getShadowedFlows()` is a new receipt listing + each contested name with its armed and shadowed definitions, and + `getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested + names; previously the displaced definition was invisible by construction, since + the flow map holds one entry per name. The `Pulled N flow(s)` line now counts + distinct names rather than registrations. + + `isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that + collapse same-named metadata answer "does a code package ship this?" with the + registry's own test instead of re-deriving it from `_packageId`. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index aae37c910d..76002f6bb8 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.2.0", + "version": "17.3.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 459184f18b..56c9819ba1 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,174 @@ # @objectstack/service-cache +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/observability@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index e82f367a6f..524ba6444c 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.2.0", + "version": "17.3.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 77b7ba9092..ba068d8913 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,166 @@ # @objectstack/service-cluster-redis +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/service-cluster@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 1a8eeef897..ab5c213253 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.2.0", + "version": "17.3.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 af73e2e379..cc57692cd5 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,340 @@ # @objectstack/service-cluster +## 17.3.0 + +### Minor Changes + +- 4bd6faa: feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) + + The substrate step (§10.3) of the accepted #11633 cross-request caching design + (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation + machinery once, before the grants cache (#11967) that will consume it, so that + leg does not carry it. **Nothing here caches anything.** + + - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine + middleware seam on every `insert` / `update` / `delete`, ahead of the whole + chain (and so ahead of any `isSystem` bypass a middleware applies). It + generalises the private counter `@objectstack/plugin-security` has carried + since #10757: the mechanism was always the engine's, and hoisting it lets a + second consumer share **one** signal instead of minting a parallel one that + watches a different set of writes. A seam rather than a list of call sites, + because a forgotten call site fails as silent over-permission and writing + through the engine is the only way to write at all — including better-auth's + own adapter. + - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged + in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a + consuming cache carries is the correctness contract; this channel is not.** No + shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a + missed message is *expected*, the bridge stays out of the write path (a + publish failure is logged and swallowed, never awaited by the writer), and the + channel only moves the *typical* convergence from one TTL to one network hop. + That statement lives in the code at the channel, where a consumer reads it. + - **The boot-time posture statement** — non-optional by the ruling. Whenever a + grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no + cross-node invalidation bus, the deployment is told so at `warn`, every boot, + naming the window it accepted and the remedy. It is a statement, not a + refusal: a TTL-bounded per-process cache is a legitimate configuration. It is + said out loud because a silently-absent invalidation bridge is how a security + control gets disabled with nobody noticing (#4785). The in-process `memory` + driver counts as **no** bus — a cluster service exists on the shipped default + while fanning out to nobody, which is the case a "is a cluster service + registered?" check answers `yes` to and is wrong about. + + **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero + subscribers, so nothing is published and nothing is invalidated; with the + shipped default TTL of `0` the bridge attaches nothing and logs nothing above + `debug`. The one composition change worth naming: `Runtime` now registers + `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` + — that is not an oversight, it is the loudest case the posture check has, and + skipping it there would put the statement's absence exactly where the missing + bus is. + + `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads + the engine's epoch when the wired engine exposes one and keeps its private + counter otherwise (test doubles, embeddings). The covered set of writes is + identical — the plugin's own middleware was already global — and it is now + identical *by construction* rather than by two files agreeing on which + operations count. +- d41d166: fix: the `./testing` subpaths are ESM-only — they no longer advertise a `require` condition vitest refuses to serve (#12985) + + Both packages published their test-harness subpath as a dual entry point: + + ```jsonc + // FROM — @objectstack/metadata-core and @objectstack/service-cluster + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js", + "require": "./dist/testing.cjs" + } + + // TO + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + } + ``` + + The `require` half was a promise neither package could keep. Both subpaths + re-export `vitest`, and vitest **refuses** to be loaded from CommonJS by + design — its CJS entry is a single `throw`: + + ``` + node -e "require('@objectstack/metadata-core/testing')" + Error: Vitest cannot be imported in a CommonJS module using require(). Please use "import" instead. + ``` + + The emitted bytes parse; the load fails inside vitest's own entry, for every + consumer and every code path. So the condition could never resolve to working + code, on any release, since it was first declared. It is removed rather than + repaired because the failure is not ours to fix: a test harness has no business + advertising a `require` condition when the test runner it re-exports does not + serve one. + + **Nothing that worked stops working**, and that is why this is not filed as a + breaking removal. A CJS consumer that resolved through the old condition got a + hard `Error` at load; it now gets a resolution error from node instead — a + different message for the same non-working call, and an earlier and clearer + one. The `import` condition, the types and the runtime API are untouched, and + every in-repo consumer already reaches these subpaths through `import` + (`@objectstack/metadata-fs`, `@objectstack/metadata-protocol`, + `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/service-cluster-redis`). + + **If you did spell it as `require`** — `require('@objectstack/metadata-core/testing')` + or `require('@objectstack/service-cluster/testing')` — switch the call to + `await import('@objectstack/metadata-core/testing')`, or move the calling + module to ESM. That is the same change the old condition already forced on + you, one error message earlier. + + `dist/testing.cjs` is still emitted (both packages build every entry in both + formats) and still parsed by `pnpm check:dual-build-cjs-loads`; it is simply no + longer reachable through the manifest. Removing it from the build is a + tsup-config change with its own risks and is not folded in here. + +### Patch Changes + +- d23ebb9: fix(metadata-core,service-cluster): stop emitting and publishing the CJS half of `./testing` (#13013) + + #13001 made both `./testing` subpaths ESM-only, dropping the `require` condition + that pointed at `dist/testing.cjs`. The build kept emitting those files and + `files: ["dist"]` kept packing them, so every release shipped bytes no exports + condition could reach. Measured with `npm pack --dry-run`, before → after: + + | package | files | unpacked | dropped | + |---|---|---|---| + | `@objectstack/metadata-core` | 22 → 16 | 3.3 MB → 3.2 MB | `testing.cjs` (28.0 kB), `testing.cjs.map` (48.4 kB), `testing.d.cts` (9.4 kB), `chunk-H2D6OJ76.cjs` (4.2 kB) + map (10.6 kB), `repository-*.d.cts` | + | `@objectstack/service-cluster` | 15 → 12 | 364.1 kB → 336.9 kB | `testing.cjs` (11.9 kB), `testing.cjs.map` (14.5 kB), `testing.d.cts` (794 B) | + + Nothing reachable changed. The whole ESM surface of both packages — `index.js`, + `testing.js`, their maps, the shared chunk, and every declaration the manifest + names — is **byte-for-byte identical** to the previous build (sha256, before vs + after). `index.cjs` changes only because what was a shared CJS chunk is now + inlined into the sole remaining CJS entry. + + Each `tsup.config.ts` becomes an array of two configs split **by format** — + ESM keeps both entries, CJS takes `src/index.ts` alone. The split is by format + and never by entry: `index` and `testing` share a chunk carrying the error + classes, and one config per entry would give `testing.js` its own copies, so + `ConflictError` reached through `@objectstack/metadata-core/testing` would stop + being the class thrown by `@objectstack/metadata-core` — which the published + contract suite asserts (`.rejects.toBeInstanceOf(ConflictError)`). + + `clean` moves out of tsup and into the `build` script (`rm -rf dist && tsup`). + tsup runs an array config through `Promise.all`, so the halves build + concurrently and a `clean` in either races the other's writes; the script-level + clean is also stronger than tsup's own, which preserves `*.d.{ts,cts,mts}` and + would therefore have left a stale `dist/testing.d.cts` behind on every rebuild + of an existing worktree. +- 2d5cee3: docs(core,service-cluster): retire the two docblocks left stale by `IPubSub`'s corrected delivery guarantee (#12836) + + #12651 corrected `IPubSub`'s contract docblock: delivery is whatever the + configured driver declares, no shipped driver exceeds at-most-once, a missed + message is EXPECTED, and handlers must be idempotent **and** tolerate loss. + Two docblocks elsewhere still described the world before that correction. + + **`@objectstack/core` — `security/authz-invalidation-channel.ts`.** It carried a + paragraph asserting, in the present tense, that the interface docblock "still + says" *At-least-once delivery*, and that repairing it was a `packages/spec` + change filed separately. That filing was #12651 and it has landed, so the + paragraph is now false rather than merely stale — it sends the next reader + looking for a live disagreement between the interface and the drivers that no + longer exists. Replaced with a plain pointer to the interface docblock. + Everything else in that docblock is unchanged: the at-most-once reasoning, the + TTL-is-the-bound rule, and the best-effort-at-the-publish-site note all still + hold. + + **`@objectstack/service-cluster` — `memory/pubsub.ts`.** The line "At-least-once + semantics held vacuously (a single in-process delivery)" was wrong on its own + terms even before #12651: the same docblock states that handler errors are + swallowed and logged via `onError`, so a handler that throws loses the message + with no retry and no persistence. That is not at-least-once in any sense, and + "vacuously" does not save it. Replaced with the honest statement — one + synchronous in-process delivery attempt per subscriber, no persistence, no + retry, no replay. + + Prose only. No behaviour change, and no test changed. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index 53466f9483..2cfba18dea 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.2.0", + "version": "17.3.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 0b7f1295f4..d99b829258 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,377 @@ # @objectstack/service-external-datasource +## 17.3.0 + +### Minor Changes + +- 3d79144: feat(service-datasource): `DatasourceDriverHandle.introspectSchema` declares the spec introspection contract, so a mis-shaped custom driver fails to compile naming the wrong field (#11381, option C of the #11123 ruling) + + **BREAKING** for TypeScript hosts that build custom external-datasource + drivers, shipped as `minor` under the repo's launch-window convention for + breaking changes. + + `DatasourceDriverHandle.introspectSchema` — the seam every host-built driver + crosses, since the framework deliberately ships no driver-by-id registry — + was typed `Promise`. The `isPrimary` → `primaryKey` retirement + (#11124, shipped in 17.2.0) named the compiler as the channel that reaches + every affected consumer, but against an `unknown` return that channel + provably never fired: a host driver spelling the per-column primary-key flag + `isPrimary`, or returning `{ tables }` with no `dialect`/`introspectedAt`, + compiled clean, and the mis-shape surfaced only as a federated table whose + records silently could not be located or updated. + + The member now declares `Promise` — the one introspection + contract in `packages/spec` (`contracts/schema-diff-service.ts`). A + mis-shaped driver is refused at compile time, at the offending field: + `Property 'primaryKey' is missing in type '…' but required in type + 'IntrospectedColumn'`, and on a fresh literal additionally `'isPrimary' does + not exist in type 'IntrospectedColumn'`. A driver that already returns the + spec shape — or a richer declared type extending it, the driver-sql / + objectql pattern (table-level `primaryKeys`, per-column `maxLength`) — + compiles unchanged. + + Runtime behaviour does not change. The `primaryKeyReader` compatibility belt + in `ExternalDatasourceService` keeps absorbing the retired spelling from + producers no compiler reaches (drivers already built against older versions, + plain-JS drivers, casts). Removing that belt is #11123 option B — a later, + separate step gated on this tightening being released and the retirement + being published — and is not part of this change. + + + +### Patch Changes + +- 776a098: docs(spec,service-datasource): `CryptoContext` documents the three producer vocabularies, and the AAD sentence narrows to the guarantee that holds (#12599) + + Documentation-only correction to the `ICryptoProvider` contract. No behavior + changes and no schema shape changes: `gen:schema`, `gen:openapi` and `gen:docs` + all regenerate byte-identically over this diff. It ships because the corrected + TSDoc is emitted into the published `.d.ts`, so it is what every consumer of + `@objectstack/spec` reads at the call site. + + `CryptoContext.namespace` / `.key` documented themselves as a settings + coordinate ("Settings namespace the value belongs to" / "Specifier key within + the namespace"), one layer below the three producers that actually construct + the type: + + | producer | `ctx.namespace` | `ctx.key` | where `handle.id` is recorded | + |---|---|---|---| + | `SettingsService` | settings namespace | specifier key | `sys_setting.value_enc` | + | the ObjectQL engine's secret-field path | **object name** | **field name** | a `secret:` ref on the business row | + | the datasource secret binder | caller-supplied, default `datasource` | datasource name | a `sys_secret:` credentialsRef | + + The prose now names all three, and `CryptoHandle.id`, `encrypt()` and + `rotateKey()` no longer describe `sys_setting.value_enc` as the general + destination of a handle. + + The load-bearing half is the AAD sentence. It read "Helps reject ciphertexts + that were copied across namespaces", which overstates what a binding over this + pair can provide: `(namespace, key)` is **one flat space shared by all three + vocabularies**, `sys_secret` declares the pair non-unique by design, and nothing + reserves a name in one vocabulary against another. The sentence is replaced by + the guarantee that actually holds — such a binding rejects a ciphertext swapped + between two coordinates *within one producer's vocabulary*, and does **not** + exclude a cross-vocabulary pair. The docblock records this as the contract's + present state and names the intended end state (a producer-discriminated AAD) + so the weak guard is not read as the designed one. + + The same settings-only prose on `DatasourceSecretBinderDeps.namespace` + ("Settings namespace recorded on the secret row") is corrected in the same + change. + + Recorded under the maintainer ruling of 2026-08-27 on #12599 (Option A now, + with the producer-discriminated AAD recorded as direction in its own ADR). +- fa5d137: feat(devx,datasource,automation): published `src/**` may only import workspace packages it declares (#10062) + + A package's non-test `src/**` was free to import any workspace package, + declared or not, and nothing checked it. The class was filed with one member + and a mitigation — the import was type-only, so nothing reached the emitted + JavaScript and rollup-plugin-dts inlined the declaration rather than naming an + unresolvable module. It grew to four members with no signal, and one of them + killed the mitigation: `service-automation/src/flow-precedence.ts` **value** + imports from `@objectstack/objectql`, which it does not declare, and because + the shared tsup config externalises only `dependencies`/`peerDependencies`, the + bundler answered by inlining objectql's implementation into + `service-automation/dist/index.js` — a second copy of another package's code, + kept correct by build configuration alone. + + `pnpm check:undeclared-dep-imports` is the gate, and the per-member fixes here + are decided one at a time rather than by a uniform policy — declaring makes a + coupling real and installable, routing it away removes it, and the two are not + interchangeable: + + * **`@objectstack/service-datasource`** now declares `@objectstack/driver-sql` + and `@objectstack/driver-memory` as **dependencies**. Both are loaded through + an *unguarded* `await import(...)` on the postgres, mysql, sqlite and memory + arms, so a consumer reaching one of those paths needed a package it was never + told to install, and would have met `ERR_MODULE_NOT_FOUND` rather than a + diagnosis. The three *guarded* driver arms — `@objectstack/driver-sqlite-wasm`, + `@objectstack/driver-mongodb`, `@objectstack/driver-turso` — are deliberately + left undeclared: each load sits in a `try`/`catch` that answers an absent + package with the fault, the consequence and the install command, and each + rides as an optional install. Declaring them would install them (turso drags + `@libsql/client`'s native bindings) and, measured on this branch, takes a live + assertion out of the tree: `default-datasource-driver-factory.test.ts` reaches + the missing-package arm with no stub precisely because the package does not + resolve from here. + * **`@objectstack/metadata-core`** now owns the ADR-0029 D9.6 provenance pair, + `isCodeArtifactBody` and `isTenantAuthored`, sunk out of + `@objectstack/objectql`'s registry by the same criterion as the write-verb + dispatch predicates and the audit governance table beside them: a second layer + needs the answer and the reverse import would either close a cycle or make the + consumer depend on the whole data engine for one predicate. `objectql` + re-exports `isCodeArtifactBody` from its original path, so its public API is + unchanged; `service-automation` imports it from `metadata-core`, which it + already declared, and its bundle no longer carries a copy of objectql's code. + + Two members stay recorded rather than remediated, because the tree already + carries the decision not to declare them together with its reason + (`@objectstack/runtime` → `@objectstack/driver-turso`, whose bare `import()` is + a host-replaceable default thunk under #6268; `@objectstack/rest` → + `@objectstack/objectql`, whose absence must degrade to `501 NOT_IMPLEMENTED` + rather than fail module load). Their ledger rows carry mechanical evidence and + go red the moment that evidence stops holding — in particular, a `type-only` + row reds on the day its import becomes a value import, which is exactly the + transition nothing caught the first time. +- 30d96ab: `ExternalDatasourceServicePlugin` types the `'data'` service with the real engine contract (`IDataEngine`, `@objectstack/spec/contracts`) and deletes its private structural `DataEngineLike` re-declaration — the workaround the untyped `IDataEngine.introspectDatasource()` forced (#11493). The introspection fallback branch now probes `getDriverByName?` (the registry member the contract declares) instead of `getDatasourceDriver?`, a spelling no engine in either repository ever had, so the degradation path is reachable for the first time. +- bbf1167: `importObject` now refuses an explicit `opts.name` that violates the ADR-0028 + namespace-prefix rule (#11061). The override used to be taken verbatim + (`opts.name ?? draft.name`) and persisted through `metadata.register('object', + …)` — the one runtime write path no namespace gate looks at — so + `POST /api/v1/datasources/:name/external/tables/:remote/import` with + `{"name": "customers"}` minted an unprefixed federated object that + `defineStack()` and the publish pre-flight would both have refused. + + The refusal answers `400 EXTERNAL_IMPORT_ERROR` (the family's registered + ADR-0112 code, in the #8016 thrown-refusal shape) carrying + `validateObjectNamespacePrefix`'s own actionable message — the same text the + publish gate serves for the identical violation, e.g. `Object 'customers' is + missing the package namespace prefix. Rename it to 'wh_customers' (namespace = + 'wh').` A compliant override (`wh_customers`), a `sys_*` platform-reserved + name, and any override on a datasource whose package resolves no namespace are + accepted exactly as before; the derived-name path (no `name` in the body) is + unchanged. +- 735f5c7: **Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). + + `ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. + + Now: + + - **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. + - **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. + - **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. +- 29d0676: `validateAll`/`validateDatasource` now read each datasource's live schema once per sweep instead of once per federated object: the sweep threads a per-call introspection memo through the validation body, so M objects on one datasource cost one remote introspection round-trip (a rejected read is shared the same way — one connection attempt, M failure rows). The memo lives and dies inside a single call, so a long-lived service never serves a stale schema to a later sweep, and direct `validateObject` calls still read live every time. The `IExternalDatasourceService.validateAll` docstring, which promised "parallelised per datasource" while the implementation parallelised per object, now states the actual behaviour. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [178f90c] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [84de7e3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [f9ffd01] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [34d3011] +- Updated dependencies [aa3f9ba] +- Updated dependencies [9d3c04d] +- Updated dependencies [d29e42f] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [3f42920] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [ebcc34e] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [107bb4b] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [431d2fb] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [6c6157a] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [1c66fe4] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [cd13488] +- Updated dependencies [df1c75c] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [3956069] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [d028b37] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [1246b4c] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 9967bd5798..b0231c426a 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.2.0", + "version": "17.3.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 0088f86bc5..085e4ac1c5 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,178 @@ # @objectstack/service-i18n +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index fe1ff712ad..c2bb3edc06 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.2.0", + "version": "17.3.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 da0654086d..6820a6535b 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,183 @@ # @objectstack/service-job +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 1a2c612871..37d194665a 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.2.0", + "version": "17.3.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 e2186cab56..29bc29a018 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/service-knowledge +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 2cfa109743..6adf09a306 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.2.0", + "version": "17.3.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 3bb4b7e8d7..0a904068f8 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,506 @@ # @objectstack/service-messaging +## 17.3.0 + +### Minor Changes + +- b6d9432: **Feature:** `MessagingService` gains a plugin-facing inbox write door scoped to the **authenticated caller** — `markReadAsCaller(caller, ids)` and `markAllReadAsCaller(caller)` (#10753). + + A plugin that pushes an "…awaiting your approval" message through `emit()` had no legitimate way to close it out again once the work was done, so the Console bell's unread badge stayed lit through a full page reload until the user hit "mark all read". The reporting project carries 30+ business hooks in that shape. + + What it was reaching for instead is the shape this closes. `markRead(userId, ids)` is the REST door's contract method (`INotificationService.markRead?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin ONE shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string. **Any plugin could mark any user's inbox messages read**, and the receipt lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check saw it either. This release is therefore both an API widening and the first tightening of in-process power on that path. + + The new pair takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId`, so "mark someone else's inbox read" has no spelling on this surface — it is unrepresentable rather than discouraged. That fits the case it was asked for exactly: the approver who clears a request *is* the recipient whose badge is stuck. + + `userId` is read, and nothing that merely resembles one: + + - `attributedUserId` is **attribution only** — its own contract states that nothing in the authorization path reads it, and a context carrying only it authorizes as anonymous (ADR-0118 D2). A `userId ?? attributedUserId` fallback would read as working and clear the wrong person's badge. + - `actor` is a service-principal label (`svc:`), not a `sys_user` id. + - `isSystem: true` with no user is refused rather than elevated: the system has no inbox to be the recipient of. + + Each refusal throws `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401` and the registered `code: 'UNAUTHENTICATED'` — and the refusal is evaluated **before** the empty-`ids` and no-data-engine short-circuits, which return `{ success: true, readCount: 0 }`. Reaching one of those with no authenticated caller would report success for a write that was never authorized, which is the silent-success shape this door exists to replace. + + Honest about what it is: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can write `sys_notification_receipt` directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. + + Nothing existing changes behaviour: `markRead` / `markAllRead` / `listInbox` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. +- 3b5f036: **Feature:** `MessagingService` gains the plugin-facing inbox **read** door scoped to the authenticated caller — `listInboxAsCaller(caller, opts)` (#11452), completing the axis the write door (`markReadAsCaller` / `markAllReadAsCaller`, #10753) established. + + `listInbox(userId, opts)` is the REST door's contract method (`INotificationService.listInbox?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin one shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string, and the read lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check sees it either. Any plugin could read any user's inbox titles, bodies and read-state — the exact shape the write door closed, on the arguably more sensitive half: inbox bodies carry rendered business content. + + `listInboxAsCaller` takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId` through the same `resolveInboxRecipient` the write door uses — one refusal vocabulary, not two. `attributedUserId` (attribution only, ADR-0118 D2), `actor` (a service-principal label) and `isSystem` are refused rather than promoted, with `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401`, registered `code: 'UNAUTHENTICATED'`. The refusal is evaluated **before** `listInbox`'s no-data-engine / no-user short-circuit, which answers a well-formed empty `{ notifications: [], unreadCount: 0 }` inbox — the read-side analog of the silent success the write door replaced. The options window (`read` / `type` / `limit`) is forwarded unchanged. + + Honest about what it is, same as the write door: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can read `sys_inbox_message` rows directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. + + Nothing existing changes behaviour: `listInbox` / `markRead` / `markAllRead` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. `resolveInboxRecipient` gains an optional third parameter naming the target-user door its refusal prescribes; it defaults to the write door's existing text, so existing call sites keep their refusal bytes unchanged. +- e4902d2: **Fix:** every keyed text column across the five `sys_notification_*` objects declares a sourced `maxLength`, so on MySQL the indexes they key are expressible **as declared** instead of the current mixed state — the UNIQUE constraints (above all `sys_notification_delivery`'s `(notification_id, recipient_id, channel)` dedup key) carried on #11627 hash-shadow columns, and every plain text-keyed index refused with a schema-sync error on each boot (#12978, the #11374 route-A class). + + The bounds and their producers (each stated in the declaration): `notification_id` / `recipient_id` / `user_id` 255 (the referenced physical id column, `varchar(255)`); `channel` 64 (registered `MessagingChannel.id` machine vocabulary, per the `sys_session.revoke_reason` precedent); `topic` 200 (= `sys_notification.topic`, the event topic these values are matched against); `digest_key` 331 (= recipient 255 + `|` + channel 64 + `|` + window date 10); `principal` 520 (widest declared selector arm `owner_of:OBJECT:ID` = 9 + 255 + 1 + 255); `locale` 16 (= `sys_email_template.locale`, the sibling BCP-47 declaration). + + **Operator-facing consequences.** Additive schema-sync never rewrites an existing column, so what changes depends on the deployment: + + - **New databases (all dialects):** the columns are created `varchar(n)` and every declared index is created directly — the dedup UNIQUE key is 255+255+64 = 574 chars = 2296 utf8mb4 bytes, inside InnoDB's 3072-byte key budget. The two wide organization-scoped UNIQUEs (`sys_notification_preference` 774 chars, `sys_notification_subscription` 975 chars) still exceed that budget on MySQL and remain carried by the #11627 SHA-256 hash shadow — enforced, with the NULL-organization caveat tracked as #12998. + - **Existing databases, Postgres/SQLite:** the declared indexes already existed (the refusal is MySQL-only) and no drift op is emitted for a bounded text field over a physical TEXT column (`narrow_varchar` deliberately fires only against a wider varchar — #11431; measured, with duals, on #12978). Boot behaviour is unchanged. What changes is the write seam: a value longer than the declared bound is now **refused loudly** instead of stored (`declared = enforced`; these identifier-family ceilings are storage-owned, #12144). + - **Existing databases, MySQL:** the columns stay TEXT. Boot-time index sync keeps re-attempting the declared indexes: the UNIQUEs stay carried by the #11627 hash shadow (created on the first boot under a post-#11627 build — unless pre-existing duplicate rows make the shadow ALTER fail loudly, in which case deduplicate first), and each **plain** text-keyed index is still refused, logged at error level by schema-sync on every boot; the object stays registered and served. This is today's behaviour, not a new refusal — what this change adds is that the refusal's remedy becomes real: `os migrate` has **no arm** that rewrites TEXT to `varchar(n)` and never truncates, so the operator route is a hand `ALTER TABLE ... MODIFY` of the named columns to their declared widths, after which the next boot creates every declared index directly. Take a backup first; restate `NOT NULL`/`DEFAULT` on MySQL `MODIFY`; run under `STRICT_TRANS_TABLES` (the default), where an over-long stored value fails the ALTER with `ER_DATA_TOO_LONG` instead of being truncated — pre-flight with `SELECT COUNT(*) FROM sys_notification_delivery WHERE CHAR_LENGTH(channel) > 64` (and likewise per column) to find such rows first. The artifact boot-migration gate is unaffected: this change emits no `destructive` drift entry (the missing-index finding is `create_index`, category `safe`). + + Graded `minor` for the same reason as the #11374 emitter changeset: on newly created tables the declared bound is now physically enforced where the dialect enforces `varchar`, and at the write seam everywhere, so a write longer than the bound that previously landed in unbounded TEXT is refused — the declaration becoming enforced, named here as a behaviour change. +- d9cf78e: **BREAKING (interface member signature):** `INotificationOutbox.ack()` now takes back the claimed record instead of a bare row id, and its compare-and-set binds the claim credential the record carries (#11859). `claim()` / `claimDigest()` declare their true return type, `ClaimedDeliveryRecord[]` — the same rows as before, with the (`claimedBy`, `claimedAt`) pair the store stamps guaranteed present — so reads of claim results do not change; the one breaking edit is at ack call sites, which hand the whole record back where they previously handed `record.id` (the caller already holds it: `ack` completes a claim, and the record is what `claim()` returned). + + Why: `status = 'in_flight'` could prove a claim exists but not whose. In the reachable sequence — node A claims a row, the send outruns `claimTtlMs`, another node's `claim()` reaps and re-claims the row, A finishes late — A's ack still matched and wrote its outcome over B's live attempt. With the credential in the predicate a late ack matches nothing, is refused with the existing `NotificationAckError` (`DELIVERY_NOT_ELIGIBLE`, ADR-0112), and writes nothing; the caller never needs to know its own `nodeId`, because ownership is proven by round-tripping what `claim()` returned. Both implementations (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) enforce it identically. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + +- 1a47a53: `INotificationOutbox.ack()` enforces its declared precondition — the row must be claimed — in both implementations, and `attempts` moves only for a real dispatch attempt + + `ack()` is the dispatcher's completion callback for a row it CLAIMED, and + neither implementation checked that. `MemoryNotificationOutbox.ack` looked the + row up by id and mutated it; `SqlNotificationOutbox.ack` read only `attempts` + by id. So `ack(id, { success: false, suppressed: true })` on an unclaimed + `pending` row succeeded, flipped the row to terminal `suppressed`, and + incremented `attempts` — which made `ack` read like the cancellation primitive + this interface deliberately does not have. + + That was a trap in two directions. It **raced the dispatcher**: between a + caller's `list()` and its `ack()`, `claim()` could take the row — `claim` is + atomic by contract and `ack` was never part of that atom — so a suppression + could land on a delivery already on the wire, or a dispatcher's real outcome + could be overwritten by a caller that thought it was cancelling. And it + **corrupted `attempts`**: the counter feeds the retry schedule + (`classifyDeliveryAttempt(result, errorClass, row.attempts, …)`), so a row + "cancelled" this way arrived at its next real attempt with the backoff already + advanced by an attempt that never went out. + + Both implementations now refuse an ack on a row that is not `in_flight`, + throwing `NotificationAckError` with this package's already-registered + ADR-0112 code `DELIVERY_NOT_ELIGIBLE` — the same refusal + `SqlHttpOutbox.redeliver` raises when its own compare-and-set misses. A refused + ack writes **nothing**: status, `attempts` and `error` are left exactly as they + were, so the row stays claimable and its backoff position stays honest. An id + matching no row remains a silent no-op — an absent row has no state to corrupt + and no claim to lose. + + `SqlNotificationOutbox` does it as an **atomic conditional update** rather than + a read-then-write, because a read cannot hold a row still and a read-then-write + is the same defect wearing a different hat. The precondition is re-stated in + the write (`where: { id, status: 'in_flight' }`), which — per #11009 — must + ride the predicate path: on the by-id path the driver binds only the primary + key and the extra predicate is silently discarded. `attempts` is incremented + inside that condition and nowhere else, so the counter can only move for a row + that was genuinely claimed. A conditional write that matches nothing is + reported rather than passed off as success. + + `NotificationDispatcher` absorbs exactly one refusal — `DELIVERY_NOT_ELIGIBLE` + — logs it and continues with the rest of the batch, because a send slower than + `claimTtlMs` legitimately loses its claim to the visibility-timeout reap, and + letting that unwind the partition loop would strand every still-valid row in + the batch `in_flight` until its own timeout expired. Any other error still + propagates. + + The sibling HTTP outbox is deliberately untouched: `assertHttpRedeliverable` + depends on `IHttpOutbox.ack` incrementing `attempts` unconditionally, so that + `attempts === 0` on a terminal row still means "parked, never sent". +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. + +### Patch Changes + +- 52954c0: `IDataEngine` declares the optional `syncObjectSchema?(objectName: string): Promise` member (#12482) — on-demand single-object physical schema sync: create/alter the object's table, or for a federated (external) object register its DDL-free read metadata (ADR-0015 §18). Additive contract catch-up under the 2026-08-25 #11833 ruling's item-4 precedent as executed by #12248: the member #12010's inventory left "not verified" is verified — implemented on `ObjectQL`, consumed cross-package by two service packages, both until now through consumer-local structural recovery (`service-datasource`'s `ConnectionEngineLike.syncObjectSchema?`, called per bound external object after its driver connects; `service-messaging`'s system-table provisioning via an `as unknown as` cast whose own comment recorded the member "lives on the concrete ObjectQL engine, not the contract"). FROM undeclared (consumers cast or re-declare structurally) TO declared-optional on `IDataEngine` (consumers read `engine.syncObjectSchema` directly and keep their runtime probes). `service-messaging` drops the now-redundant cast (behaviour unchanged). Optional, so existing `IDataEngine` implementers and test doubles are unaffected. No runtime change. +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- e577445: The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient + + `NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` + path resolves `(name, locale)` with **one** locale for the whole notification. + The same retired promise survived outside the spec file, in the places an app + author is most likely to read it: + + - `service-automation/src/builtin/notify-node.ts` — the `template` field's + `configSchema` description, i.e. the text rendered in the **Studio form** the + author fills in. It said the row is "resolved by (name, recipient locale) at + delivery time and rendered per recipient". + - `content/docs/automation/email-templates.mdx` — the only site that stated the + conclusion outright rather than merely licensing it: "so one node mails each + person in their own language". + - `service-messaging/src/messaging-service-plugin.ts` — the channel-registration + log line, which advertised "resolve sys_email_template per recipient locale". + - Two internal comments in `notify-node.ts` and one in its test, describing the + payload the outbox snapshots as carrying a per-recipient-locale resolution. + + None of that is what the delivery path does. `payload.locale` is interpolated + **once, before fan-out**, so it is a single value for the whole notification, and + its fallback is the deployment default (`II18nService.getDefaultLocale()`). The + platform has no per-user locale to read — `sys_user` carries no locale column, + and request-scoped locale does not exist at async delivery time — so recipients + whose personal languages differ all receive the same template row. A per-user + locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers + in as an override at that same seam when it lands; the corrected wording dates + the deferral so it reads as a decision with provenance rather than an oversight. + + The gap was worth correcting because the wording licensed exactly one action — + convert `notify` nodes on the belief that non-English recipients get non-English + mail — and that action is a **net regression**: `TEMPLATE_*` failures classify + `permanent` and dead-letter, and the inbox channel starts requiring an email + service with `renderTemplate()` where inline text needed none. + + Text only: no schema accepts or refuses anything it did not before, no delivery + behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` + asserts the form description names `payload.locale` and the deployment default + and refuses a bare "recipient locale", so a later edit cannot quietly restore the + promise. +- 598b7ec: fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) + + `os i18n extract` merges gaps only, so a revised source string leaves the previous + revision standing in every translated locale — in sync by key, green under + `check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves + `check:i18n-stale-fill` froze in its baseline are re-translated here from the + **current** `en` source, and the baseline is ratcheted to empty in the same change. + + User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: + + - `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a + `"certified"` governance flag that was removed from the declaration in 16.0. + - `sys_webhook.fields.method.help` — all three locales served the pre-revision method + enumeration after the source became a prose description. + - `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now + Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose + uses and which carries no plural inflection. + - `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and + `zh-CN` a translation of the same superseded source; all three now carry the + PARKED / terminal-row clause the source documents. + - `sys_notification_subscription.fields.principal.help` — the selector list was missing + the `owner_of:object:id` and bare-email forms in all three locales. + + No schema, export or runtime behaviour changes: translated-locale leaf values only, + plus the shrink-only ratchet baseline. +- ffbb7a1: Stamp `organization_id` on flow-produced notifications and on `markRead` + receipts, so the notification family stops writing org-less rows + + An application project's read-only inventory found `sys_inbox_message`, + `sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` + carrying `organization_id = NULL` on **100%** of their rows — existing rows and + same-day new ones alike, while `sys_approval_request` in the same database + carried an organization on every row. Ruled a gap, not a design choice. + + Everything below the messaging ingress was already threaded: `emit()` stamps the + `sys_notification` event, the inbox channel stamps `sys_inbox_message` and its + `delivered` receipt, and the outbox carries the value onto + `sys_notification_delivery`. Each of them reads `EmitInput.organizationId` — + and the `notify` flow node, the dominant producer, never supplied it. Its local + structural mirror of `emit()` did not even declare the field, so the value could + not have been passed. One missing argument, four tables at 100% null. + + The node now threads the organization from the run's own acting context + (`AutomationContext.tenantId`), the same source the `collab.mention` producer in + `@objectstack/plugin-audit` already uses, so the two notification producers agree + about whose organization a notification carries. + + A second producer of the same table is fixed alongside it: the `read` receipt + `markRead` inserts — written when a user reads a notification whose delivered + receipt never landed — named no organization at all. It now carries the + organization of the `sys_notification` row it is about. + + There is deliberately **no fallback limb** in either producer: not "the current + organization", not the install's first organization, not the recipient's first + membership. A run with no organization in scope still emits and still writes its + rows, and the `notify` node warns audibly naming the topic and the consequence. + A wrong `organization_id` is worse than a null — a null is visibly missing, + while a wrong value is silently authoritative to every report, export and + cleanup script that filters by organization. + + Forward-stamping only. Existing rows are not backfilled and no migration ships. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 31cedc75ce..654ea2f615 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.2.0", + "version": "17.3.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 f72fc91269..4625ecaa06 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,183 @@ # @objectstack/service-package +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [54e2d36] +- Updated dependencies [b745157] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [d23ebb9] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [2852acc] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [15d55fb] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [d41d166] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 1d2aa7aa42..d511879f45 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.2.0", + "version": "17.3.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 ac285d755a..cd0bb4c966 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,183 @@ # @objectstack/service-queue +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 3aa1196fd2..3ff1daa298 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.2.0", + "version": "17.3.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 87ee91b6af..775135be95 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,292 @@ # @objectstack/service-realtime +## 17.3.0 + +### Patch Changes + +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 672c22a134..9bc4bc9f93 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.2.0", + "version": "17.3.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 f6dde33288..e7bc241c1d 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,393 @@ # @objectstack/service-settings +## 17.3.0 + +### Minor Changes + +- 8e31083: feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) + + The audience posture shipped by #11739 (`invite_only | email_domain | open`, + default `invite_only`) was switchable only from stack config at boot; a + self-host admin had no console channel. The `auth` settings namespace now + carries an `audience` group — three new authorable keys, which is what a host + sees and why this is `minor`: + + - `audience_posture` — a select over the closed vocabulary (the option table + is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override + door); + - `audience_allowed_email_domains` — newline- or comma-separated bare domains + (exact, case-insensitive matching; subdomains need their own entries); + - `audience_self_registration_permission_set` — the `sys_permission_set` name + each self-registrant receives. + + `bindAuthSettings` maps the three keys — one atomic declaration — to one + `AuthManager.applyConfigPatch({ audience })`, which replaces the whole + audience object and validates the MERGED result. Every #11739 invariant holds + through the new channel: a self-registration posture with verification + explicitly off, an empty domain list under `email_domain`, and a missing or + `admin_full_access` permission set are all refused loudly (the standing + config keeps ruling — fail closed), and off-vocabulary postures are refused, + never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT + settings values apply: the manifest defaults never mask a deployment's + boot-config declaration. Switching back to `invite_only` always applies — + leftover text in the posture-hidden sibling fields cannot make closing the + wall refusable. +- a58eac3: **Security:** `LocalCryptoProvider` selects its crypto posture from the deployment signal only. A test-runner variable inherited by a spawned server can no longer disarm the production key refusal (#11352). + + `detectMode` read `env.VITEST` as a vote for `'test'` posture: + + ```ts + if (env.VITEST || env.NODE_ENV === 'test') return 'test'; + ``` + + `'test'` is not a softer flavour of `'production'`. It is the branch that takes an ephemeral key, never touches disk, and **never refuses to boot** — and that refusal is the reason the class exists: minting a key at boot makes every previously-written `sys_secret` value (encrypted settings, `secret` fields, datasource credentials) undecryptable after the next restart or on another node, invisibly at encrypt time. So one runner variable decided whether a security gate ran at all. + + Runner variables are **inherited**. Vitest sets `TEST`, `VITEST`, `VITEST_MODE`, `VITEST_WORKER_ID` and `VITEST_POOL_ID` on its worker, and every process that worker spawns with `{ ...process.env }` receives them. Measured on this repo: a real `os serve` spawned that way booted with production auth and **test** crypto. `packages/cli/test/serve-node-env-production-default.e2e.test.ts` — a pin whose entire subject is *"unset `NODE_ENV` means production"* — ran that way for its whole life, and nothing said a word, because a gate that does not run prints nothing. It surfaced only incidentally, while closing the sibling `TEST` leak into better-auth's origin check one layer down. + + **What changes for you.** A process that boots with `NODE_ENV=production`, no `OS_SECRET_KEY`/`OS_DEV_CRYPTO_KEY`, no persisted key file, no `OS_CRYPTO_AUTOKEY` — and a runner variable in its environment — now **refuses to start** instead of running on an ephemeral key. That is the documented fail-loud guarantee arriving where it was previously skipped, not a new restriction: supply the key the refusal names. + + ``` + OS_SECRET_KEY=$(openssl rand -hex 32) + ``` + + **What does not change.** In-process unit tests still get `test` posture — ephemeral key, disk never touched. The `VITEST` read is deleted rather than narrowed because vitest sets both variables on the same worker (`prepareVitest()`: `process.env.VITEST = "true"; process.env.NODE_ENV ??= "test";`, repeated as `NODE_ENV: process.env.NODE_ENV || "test"` in each worker's env). In-process the two spellings are indistinguishable; they differ only for an **inheriting child**, which is precisely the defect. `NODE_ENV` remains the one signal, and a deployment that declares itself a test deployment still gets test posture. + + The whole class is now gated: `pnpm check:runner-env-posture` refuses `TEST`, `VITEST`/`VITEST_*` and `JEST_WORKER_ID` anywhere in product source, so the next author is told at authoring time rather than by an operator whose secrets stopped decrypting. `NODE_ENV` is deliberately not banned — it describes the deployment, and a deployment may declare itself a test deployment; a runner may not declare it on the deployment's behalf. + +### Patch Changes + +- 99ccbb9: Tell the operator in Settings → AI that `@objectstack/service-ai` has no + open-edition version to install, instead of three bare "Mount it" lines + + Configure any real LLM provider in **Settings → AI** and press *Test + connection*, and the built-in fallback handler answered — on all three + real-provider branches — "Mount `@objectstack/service-ai` to exercise live + calls." This platform's own capability roster says that cannot be done: + `PLATFORM_CAPABILITY_PROVIDERS.ai` in `@objectstack/spec/kernel` declares + `edition: 'cloud'`, which `CapabilityEdition` defines as "realized only by a + cloud runtime tier; there is **no installable version in the open edition**" — + the un-followable "add it to your dependencies" that framework#3366 exists to + make legible. The package is in no directory of this repo (0 path hits for + `/service-ai/` on `main`; `/service-settings/` returns 64 and + `/embedder-openai/` 8 under the identical command, so the zero is real). + + The instruction is **kept** — an operator may well have a cloud tier — and + gains the boundary it was missing, so one who does not can see the path is + closed to them. + + Worse than a 404, the install succeeds. Measured 2026-08-23 against the public + npm registry (unauthenticated, `@objectstack/spec` + `@objectstack/cli` as + positive controls, `@objectstack/service-ai-studio` — the sibling + `edition: 'cloud'` entry — as a negative control returning 404): + `@objectstack/service-ai` returns **200** with 57 versions, and the highest is + **10.3.0** (2026-06-23) — entirely below the 11.3.0 cut the roster note names, + i.e. the pre-cloud tail left behind on the registry. A determined operator + following the old sentence installs a seven-major-old AI runtime that + exact-pins `@objectstack/spec@10.3.0` against this repo's 17.2.0, resolving a + second spec beside this one. The new message says so, so nobody discovers it + from a dependency error. + + The boundary sentence is **read from the roster**, not hand-written a fourth + time: `note` is documented as "surfaced verbatim inside the preflight / boot + error so the message carries its own context", and `packages/cli`'s capability + preflight already interpolates it the same way. The three provider prefixes + stay distinct — only the shared trailing sentence converges. + + No behaviour change: `ok` and `severity` are untouched on every branch, and the + embedder hint at the fourth site is deliberately left alone (its package + **is** built here, so that instruction is followable as written) and pinned by + a contrast test. +- e7f56d6: Make the settings engine facade and the metadata database loader bind the row + they resolved, not a row the payload names + + Two ingresses resolved an authoritative row id and then folded it into the + write payload with the **losing** spread order — `{ id, ...data }` — so a + caller-supplied `data.id` spread over the id the ingress had just resolved and + silently retargeted the write: + + - `wrapEngineAsSettingsEngine`'s by-id `update` branch + (`@objectstack/service-settings`), whose id comes from the caller's + `where.id`. + - `DatabaseLoader._update` (`@objectstack/metadata`), whose id arrives as a + separate parameter every caller resolves first (`existing.id`, from the read + immediately above). + + Both now spell it `{ ...data, id }` — the operation's id **after** the spread, + so it wins. That is the convention the repo's other two ingresses already + document: `rest-server.ts`'s batch update arm ("the operation's id AFTER the + spread, so it wins") and `protocol.updateData`'s #6479 fix + (`{ ...request.data, id: request.id }`). + + **No wrong write is known to have been reachable.** Both sites' current callers + build fresh field literals and never put an `id` inside `data`, so this is + hardening a fragile pattern rather than repairing a measured defect. What makes + it worth the three characters is that neither site can be caught downstream: + both pass **no `where`** to the engine, so the payload is the only id the engine + ever sees, and the engine's conflicting-id refusal (`UPDATE_ID_MISMATCH`, 400) + needs two disagreeing declarations before it can fire. The fold is the entire + trust boundary at both sites, and it is one refactor — a caller handing back a + row copy, and rows carry `id` — from the #6479 shape. + + Both are pinned with a payload whose `id` names a **different** row than the + one the ingress resolved, asserting the resolved row is still the row bound. A + pin exercising a payload without an `id` would have passed against both + spellings. The doubles answer "which row does this bind?" with the producer's + own `assertEngineUpdateDispatch`, so they cannot be kinder about it than a + running server. +- 66bbb4c: Tell the operator where `@objectstack/knowledge-turso` comes from instead of + naming a package this repo does not build + + An operator who selects the `turso` knowledge adapter in **Settings → AI & + Embedder** and runs the connection test was told: "Mount + `@objectstack/knowledge-turso` to exercise live calls." That package is in no + directory of this repo (0 path hits on `main`; `knowledge-memory` and + `knowledge-ragflow` return 8 each under the identical command, so the zero is + real), and the message said nothing about where it does come from — leaving the + instruction un-followable at exactly the moment it is read. + + The prior question the card turned on — *is it published anywhere?* — is now + measured rather than assumed. Against the public npm registry on 2026-08-23, + with `@objectstack/spec` and `@objectstack/cli` as positive controls and + `@objectstack/security-enterprise` as a known-private negative control: + `@objectstack/knowledge-turso` **is published**, `latest` 6.9.0 (2026-05-27), + nine versions from 6.4.0. So the option stays — dropping it would have deleted a + working adapter. + + What it is *not* is co-installable with this platform version: 6.9.0 exact-pins + `@objectstack/spec@6.9.0` while this repo ships 17.2.0, so mounting it resolves a + second spec rather than reusing this one. The runtime message now names the + package, says this platform does not ship it, points at the ObjectStack Cloud + monorepo where it is built, and tells the operator to check for a release + matching their platform version — the framework#3366 discipline that an install + hint must carry its own edition/version boundary. + + The manifest's adapter-list comment loses the undated "mirrors the plugin + packages currently published" claim that stopped being true with nothing to + catch it, and gains the measurement with its date and method so the next reader + can re-run it. No behaviour change: `ok` and `severity` are untouched on every + branch of the test action. +- fc8627e: docs(service-settings): state `getMany`'s all-or-nothing key validation on the declaration that owns it (#11680) + + Documentation and a pin. **No behaviour change** — the accept set and every + resolved value are byte-identical. + + `SettingsService.getMany` validates **every** requested key against the + namespace manifest before it reads a single env override and before it loads a + single row, so one undeclared key rejects the whole call with + `UnknownKeyError` (`code: 'SETTINGS_UNKNOWN_KEY'`) and the caller receives + nothing — not the subset it was entitled to. N per-key `get()` calls behave + differently on exactly that input: each declared key still answers, and only + the undeclared one throws. + + The doc comment was otherwise detailed — it explained the grouped row load and + the env-override ordering, and claimed row-for-row equivalence with per-key + `get` "BY CONSTRUCTION" — but never drew this line. That equivalence claim + holds for every key that *resolves* and not for the refusal, so a batched + consumer had to rediscover the rule from a test. `resolveLocalizationContext` + was the first to inherit it and had to record the consequence locally: a host + registering a **partial** `localization` manifest loses all its keys at once + and drops to a shorter cascade, where the per-key path would still have + resolved the declared ones. + + `getMany`'s doc comment now states the rule, its blast radius, why validating + ahead of the grouped walk makes the refusal independent of key order and scope + grouping, and what a caller on a partial manifest should expect. + + The pre-existing pin asserted only `rejects.toThrow(/nope/)` — green whatever + the blast radius is. A sibling pin now asserts the property instead: the error + envelope (`code: 'SETTINGS_UNKNOWN_KEY'`), that **zero** rows were loaded + (the refusal is up-front, with the undeclared key last in the request), and + the contrast that per-key `get()` still answers each declared key. +- 050d8d8: `loadRows` user-keyed engine loads now include tenant/global rows (`$or` over `user_id`/upper scopes), mirroring the in-memory branch. Fixes the user→tenant→global read cascade dying at the user level and upper-scope locks never firing on user-scope writes, on engine-bound deployments (#11228). +- 47d9b77: The SMTP port range `1-65535` is now declared once and the refusal is generated from it (#12993). It had been hand-written three times across two packages: the enforcement in `SmtpTransport`, the `(expected 1-65535)` literal in the very next line's message, and `min: 1, max: 65535` on the mail settings form's `smtp_port` field — which `@objectstack/service-settings` really does enforce (`declaredBounds` / `validatePatch`), so it is a second door rather than decoration. The first two were adjacent lines, the cheapest possible drift: changing the check without changing the sentence yields a refusal that misstates its own rule, and nothing fails. + + `transports/smtp-port-contract.ts` now owns `SMTP_PORT_MIN` / `SMTP_PORT_MAX`, the predicate that applies them and the sentence that states them. The message text is **generated** rather than re-spelled, so the second spelling no longer exists to drift — the stronger of the two repairs the card named, since it deletes the drift instead of checking for it. + + Nothing is accepted or refused that was not before. The move is pinned as behaviour-preserving against the previous inline expression, kept verbatim as the oracle, over a table that includes both edges, the non-finite values and the non-integers. + + The settings manifest keeps its own numbers deliberately. `@objectstack/service-settings` does not depend on `@objectstack/plugin-email`, and the plugin depends on the service only as a test-only devDependency; making the manifest import the constant would add a runtime edge from a service to a plugin, invert the layering and pull `nodemailer` into the settings service's install closure. So the two are held equal by a cross-package assertion over that existing devDependency instead — the same mechanism that already holds the provider dropdown equal to `EMAIL_TRANSPORT_PROVIDERS` — and no new dependency edge is created in either direction. + + This range is **not** the CLI's listen range and must never be merged with it: `os serve` floors at `0` because port 0 asks the OS to choose one to listen on, while an SMTP port is a destination and floors at `1`. Collapsing them onto one constant would silently make `0` a legal SMTP port, so the floor is pinned explicitly. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index 33383f8b49..a35142b228 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.2.0", + "version": "17.3.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 c6a141918c..2a791cb9ed 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,188 @@ # @objectstack/service-sms +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [56d3c7a] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [3c0f3ea] +- Updated dependencies [26deb31] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [911da5f] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index b9fde3e2f0..afaea4ba6d 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.2.0", + "version": "17.3.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 b10e228950..eb03e34b17 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,449 @@ # @objectstack/service-storage +## 17.3.0 + +### Minor Changes + +- c94be62: fix(service-storage): stamp `sys_file` with the acting organization, and backfill the rows that were never stamped (#12745) + + `sys_file` is a tenancy-ENABLED object — it declares no `tenancy` key, so + `isTenancyDisabled()` reads `false` and `applySystemFields` provisions + `organization_id` on it. Nothing ever wrote that column: + `StorageMetadataStore.createFile` inserted with **no execution context at all** + (the string `context` appeared 0 times in `metadata-store.ts`), so the SQL + driver's `injectTenantOnInsert` had no `tenantId` to stamp from and every row + landed NULL. Both callers already held the session — `storage-routes.ts` reads + `owner_id: session?.userId` ten lines below each `createFile` — so the + organization was in hand and simply had nowhere in the signature to go. + + Maintainer ruling 2026-08-28 on #12745: **A with backfill** — stamp forward AND + repair the existing rows. Both halves ship here. + + **Forward stamping.** `createFile(rec, context?)` takes a new optional + `StorageWriteContext` (`{ organizationId }`) and passes it to the engine as + `{ context: { tenantId } }`. The column is deliberately NOT written onto the + payload: whether the object has a tenant column, and whether an explicit value + on the row wins, are the driver's answers (`injectTenantOnInsert` → + `resolveTenantField`), and a metadata store re-deciding them one package away + from the schema is how a stamp starts failing on installs that opted the object + out. Both upload doors thread the session's active organization, and the + plugin's session bridge now reports it (`session.session.activeOrganizationId`, + the platform's existing spelling). ⛔ No membership fallback: here the value + becomes a *wall*, and a file stamped from a guessed membership is a file its + uploader can no longer see from the organization they were acting in. A session + with no active organization stamps nothing, exactly as before. + + **Why the backfill is not optional.** The SQL driver's tenant predicate is + NULL-tolerant (`organization_id = :tenant OR organization_id IS NULL`), but + Layer 0 AND-composes a strict `organization_id = ` above it and + "the conjunction is the strict equality alone". Forward-only stamping would + therefore split the table: new files org-walled, every existing NULL-org file + invisible to **every** principal. (`single` posture is inert — + `computeTenantLayer0Filter` returns `null` — so single-tenant installs are + unaffected either way.) + + **The backfill.** A one-off, idempotent, dry-run-first sweep + (`backfill-sys-file-organizations.ts`), following the tree's own precedent in + `plugin-approvals`. It derives each row's organization from the file's HOLDERS — + the exclusive field reference (`ref_object`/`ref_id`) and every `sys_attachment` + join row — and stamps **only where they all answered and all answered the same + organization**. ⛔ Rows that cannot be derived unambiguously stay NULL and are + REPORTED, never guessed, with the residual-NULL count and its per-reason + breakdown on the report and in the rendered text — for the dry run as well as + the applied run. It is a one-off operational module: not exported from the + package index and not shipped in `dist`. + + ⛔ Scope: `sys_file` only. The precedent requires a maintainer order per table + and the ruling is that order for this one table — `sys_upload_session` sits in + the same package with the same NULL column and is deliberately not swept. + + **Compatibility.** `createFile`'s new parameter is optional and + `StorageRoutesOptions.resolveSession` only WIDENS its return type + (`{ userId? }` → `{ userId?, organizationId? }`), so existing resolvers and + callers keep compiling and keep their current behaviour. + +### Patch Changes + +- c3c72a4: Record file-field hydration now answers the same question about a `sys_file` tombstone that the download path answers (#11427). `#10246` stopped `GET /api/v1/storage/files/:id` treating a tombstone (`status: 'deleted'` + `deleted_at`) as the last word — it asks the reap guard's own `findFileHolder` and serves the row for as long as something still holds it — but the record read kept the older `status === 'committed'` rule. One `sys_file` row therefore answered `200` at the download endpoint and a bare id inside a record payload, which UI and export render as "this record has no attachment". + + The population is narrow and unchanged in every other respect: `claimFile` already un-tombstones a field file synchronously when a record re-points at it, and attachments-scope files are never reached by field hydration, so what this closes is the residual the reap guard's sweep-time re-verification names — hook races, direct-driver writes, and future trash restore. A tombstone nothing holds still hydrates as a bare id, and a `pending` upload is untouched. + + The predicate is not re-derived in the engine. `ObjectQL` gains `registerHeldFileResolver` (type `HeldFileResolver`), which the storage plugin fills with `findHeldFiles` — the batched form of `findFileHolder`, asking the same union of `sys_attachment` join rows and the `ref_*` ownership columns. Batched because hydration runs over many rows per read: a read with no tombstone costs nothing, and the residual case costs one extra query for the whole read rather than one per file. Engines with no storage plugin keep tombstones un-hydrated exactly as before. +- 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642) + + Maintainer ruling #12069 Option A (#11671) landed translation provenance as + **two** halves: `os i18n extract --source-hashes` RECORDS which source revision + a generated leaf is still a byte copy of, and `withSourceFallback` READS those + records at serving time and substitutes the current source for a leaf whose + source has moved underneath it. The recording half was then rolled out to every + bundle set. The reading half was not — measured on `main`: provenance + **recorded in 9 of 9** bundle sets and **read at serving time in 1**. + + The other eight assembled their `TranslationBundle` straight from the raw + generated modules and never consulted the companion sitting beside them, so + they recorded the drift and went on serving the superseded draft. Nothing said + so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` + counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale + rule needs a SECOND locale holding the same stale bytes before it can testify. + The measured case had one locale and no second witness. + + All eight are wired here, in the shape `@objectstack/platform-objects`'s own + `metadata-translations/index.ts` uses — the committed + `.source-hashes.generated.ts` passed as the fourth argument, the third + left `undefined` because these sets have no hand-authored sections. Provenance + is now recorded in 9 of 9 sets and served in 9 of 9. + + `@objectstack/plugin-webhooks` was the last of them and is the only one whose + manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, + which that package did not declare. It was **already in that package's install + closure** through `@objectstack/service-messaging`, so the edge declares a + resolution that already resolved rather than adding a package to the graph — + and relying on it undeclared would have been a phantom dependency under this + repo's strict package manager. + + `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this + cannot silently come apart again: a bundle set that commits a companion and + does not consult it at serving time now fails the build, including a tenth set + that lands tomorrow. + + **Graded `patch`, and the grade is the interesting part.** No API changes, no + new exported surface, and no key set moves — substitution was chosen over + deletion precisely so key-set claims stay put (ruling #8765 Option B). What + changes is which STRING a stale leaf serves. On this tree that is **zero + leaves**: a record is only ever written for a leaf that IS a byte copy of the + current source, so the companions arrive 0-stale by construction. The change is + in what happens the next time a source string moves — the reader sees the + English source rather than a superseded draft of it, which is the same + degradation an untranslated key already produces and not a new state. +- de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559) + + `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A) + records, per generated translation leaf, the digest of the source revision that + leaf is **still a byte copy of** — the one signal that tells a stale fill from a + real translation once the source has moved and the two stopped being + distinguishable by value. It shipped opt-in, and exactly one of the nine i18n + bundle sets opted in. A landed detector, a changeset announcing it and a green + gate read together as *"generated translation staleness is now caught"*; for + eight of nine sets it was not, and the thing making it not caught was a single + absent flag in an extract config — invisible from all three of those surfaces. + + **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`, + `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`, + `service-realtime`, `service-storage`. Each documents `source-hashes` in its + extract config and commits three `.source-hashes.generated.ts` + companions, produced by the same extract run as the bundles they sit beside + (`check:i18n` compares them byte-for-byte, so they cannot be written by hand). + `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for + `platform-objects` where it reported 8. + + **Records count what is currently RECORDABLE, never what is covered.** A record + is written only for a leaf that *is* right now a byte copy of the current + source, so a fully translated locale starts with an empty table — which is the + instrument armed, not an instrument that measures nothing: the entry appears by + itself on the first extract after a leaf becomes a fill. Measured at this + commit, per set over its three translated locales: `service-messaging` 289, + `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20, + `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and + ja-JP are fully translated and start empty), `service-realtime` 0 (all three + locales fully translated). **419 records written across the eight sets, 0 + stale.** + + **One extractor fix the rollout forced.** `--source-hashes` had one user, and + that user commits both generated sections, so the interaction with + `--no-metadata-forms` had never been exercised. The provenance table is computed + over every generated section the extractor builds; the eight sets here commit no + metadata-forms bundle, and their `metadataForms` subtree — absent from their + merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf + of it was recordable. First measured on `plugin-audit`: **763 records, of which + 2 were its own objects and 761 were digests of the Studio metadata-form baseline + `@objectstack/platform-objects` owns.** Those records are unreadable in the + package holding them and would have rewritten all 24 companions on any unrelated + `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8 + and every `bundle-ownership.test.ts` keep out of committed bundles. The + companion now covers exactly the sections a run commits, decided by the same two + predicates that decide the bundle files. `platform-objects` commits both, so its + three committed companions are byte-for-byte unchanged. + + **Grade: `patch`, and behaviour on the day it lands is unchanged for every + leaf.** A record is written only where a leaf is currently a byte copy of the + **current** source, so every record written equals the current digest and none + of them can be stale; the mechanism cannot arrive red. No committed translation + bundle changed a byte, no public API moved, and no leaf's rendered text changed. + `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the + package's entrypoint does not re-export the extractor utils. + + **What this does not do**, stated so the boundary is not inferred wrongly a + second time: these eight sets now *record* provenance. Reading it at serving + time is `withSourceFallback`, and that is still wired in + `@objectstack/platform-objects` alone — so a stale fill in one of the eight is + now recorded and reportable, but not yet substituted at runtime. Tracked + separately. +- a41069b: **Fix:** a tombstoned `sys_file` that something still holds is downloadable again — no 30-day 404 in between (#10246). + + Re-pointing a `sys_attachment` join row onto a file inside its 30-day grace-window tombstone has always been byte-safe: the reap guard re-verifies references at sweep time, finds the new holder, un-tombstones the row and vetoes the reap. But the sweep is the only thing that ever asked, and `sys_file`'s declared lifecycle (`ttl { field: 'deleted_at', expireAfter: '30d' }`) nominates a tombstone only **after** the window expires — measured candidates inside the window: `[]`. So the file simply sat at `status='deleted'` while `GET /api/v1/storage/files/:fileId` and `/files/:fileId/url` refused anything not `committed`. A live attachment could point at a file that 404s for up to 30 days and then silently starts working. + + **What changed:** the two download endpoints stop treating the tombstone as the last word. They now ask the reap guard's own `findFileHolder` — the single definition of "is anything still holding this file?", a union over `sys_attachment` join rows and the `ref_*` ownership columns — and serve the file for exactly as long as that answers yes. + + **What did not change**, deliberately: + + - **No lifecycle verb was added.** There is no un-tombstone, revive or resurrect on the read path; the download writes nothing to the row. Revival remains solely the sweep guard's, which is why the fix is a read-side predicate and not a second revival mechanism (the duplicate-mechanism hazard #10241 avoided). The tombstone stays, and the sweep still reaps when the last holder goes. + - **`pending` is still refused.** Only the `deleted` limb widened; an upload that was never completed has no bytes to promise. + - **Authorization is untouched.** A served tombstone goes through the same `authorizeFileRead` gate as any other file — `AUTH_REQUIRED` (401) and `ATTACHMENT_DOWNLOAD_DENIED` / `FILE_DOWNLOAD_DENIED` (403) are unaffected. Servability is not authorization. + - **Bare kernels are unaffected.** With no data engine there is no holder question to ask, so tombstones stay refused exactly as before. + + The read side and the sweep now answer the same question from the same code, so a file the download path serves is by construction a file the next sweep would veto rather than reap — and the instant the last holder goes, both flip together. That pair is what the new tests pin; the 404 text on the refusal changed from "File not found or not committed" to "File not found or not downloadable" to match (the `FILE_NOT_FOUND` code is unchanged). +- 96940b7: feat(storage): report-only inventory for stranded `sys_file` orphans, plus `os storage orphans` (#10950) + + The tombstone repairs in #10171 (update verb) and #10240 (delete verb) are forward-only: + they changed what the next write does and touched no row already written. Every + attachments-scope file orphaned before them still sits at `status='committed'` with + `deleted_at` NULL and no `sys_attachment` join row — and `sys_file`'s declared lifecycle + nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or + `retention { onlyWhen: { status: 'pending' } }`, so such a row matches **neither**. It is + never a candidate, the reap guard is never asked about it, and its bytes are never + reclaimed. The leak is permanent rather than late. + + This ships the measurement half only, per the maintainer's ruling on #10950: + + - `inventoryStrandedFileOrphans()` — a read-only reconciliation pass that walks + attachments-scope committed `sys_file` rows and reports how many are stranded, their + byte magnitude, and why each excluded row was excluded. `formatStrandedOrphanInventory()` + renders it; those two plus the inventory's result types are what + `@objectstack/service-storage` publishes — the shared ownership predicate stays internal, + since nothing outside the package pulls on it. + - `os storage orphans` — the operator-invoked surface, with `--json` for a machine-readable + payload. There is no `--apply` and no write path, deliberately. + + **It writes nothing, tombstones nothing and deletes nothing.** Authorising the destructive + backfill is a separate decision that these numbers exist to inform; a tombstone written + here would start a 30-day clock ending in an irreversible byte delete. + + The ownership question is not reimplemented. `createSysFileReapGuard`'s "is anything still + holding this file?" test — zero `sys_attachment` join rows **and** empty `ref_*` ownership + columns — is extracted as `findFileHolder()` and called by both the guard and the + inventory, so "the same question, never a weaker one" is a property of the code rather + than a claim in a comment. A file with zero join rows that is `ref_*`-owned (ADR-0104 / + #3459) is a live file and is excluded from the count. + + Behaviour of the reap guard is unchanged — the extraction is a pure refactor, and the + guard's existing pins cover it. Both counts are labelled `attachments` scope: files in the + other scopes are governed by the field-reference seam and are reconciled by + `verifyFileReferences`, which skips attachments-scope files, so the two passes partition + the population rather than overlapping. +- 2ff01cf: fix(service-storage): stamp `sys_upload_session.organization_id` from the acting session (#12928) + + `StorageMetadataStore.createSession` inserted into `sys_upload_session` with no + execution context, so the SQL driver's `injectTenantOnInsert` had no `tenantId` + to stamp from and every chunked-upload session row landed with + `organization_id` NULL — on a tenancy-ENABLED object (the declaration carries no + `tenancy` key, so `applySystemFields` provisions the column unconditionally). + This is the `sys_upload_session` sibling of the `sys_file` gap fixed in #12745, + and the chunked-upload door already held the value: it threads the identical + `session?.organizationId` into the `createFile` immediately above. + + `createSession` now takes the same optional `StorageWriteContext` as + `createFile` and hands the engine `{ context: { tenantId } }`, so the platform's + existing insert-side chokepoint decides the rest — whether the object has a + tenant column at all, and whether an explicit value on the row wins. A caller + with no organization passes no options and the row lands unstamped exactly as + before. + + Maintainer ruling 2026-08-29, verbatim and untranslated: 「同意」 — forward stamp + only. There is deliberately **no backfill**: rows already NULL age out through + this object's own ADR-0057 TTL sweep. That premise is verified rather than + assumed — `sys-upload-session-ttl-sweep.test.ts` drives the shipped declaration + through the real `LifecycleService` against live SQL and pins that an expired + NULL-organization row is reaped, that a stamped row is reaped by the same + sweep, that a live session survives it, and that a run with no declaration reaps + nothing. + + Why an unstamped row mattered even without a cross-tenant read: both walled + Layer 0 predicates are exclusive (`{ organization_id: }` under `isolated`, + `{ $in: [...] }` under `group`), and neither matches NULL — so on a walled + deployment an unstamped session row was invisible to its own tenant, the same + silent-empty class `sys_api_key` was renamed to avoid. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [37e82eb] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [e0abc38] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [4d5b4f8] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index b3ad456897..4311fab1fb 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.2.0", + "version": "17.3.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 b5a6157e76..a182364692 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,4057 @@ # @objectstack/spec +## 17.3.0 + +### Minor Changes + +- 387e231: feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519) + + **BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + Two independent channels could name a post-success destination for one + `type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`, + validated and visible in metadata) and the handler-returned `{ redirectUrl }` + convention (runtime-only). The spec ruled each surface's default in isolation + and said nothing about an action carrying both — so the renderer had to pick, + and the pick lived only in one renderer's implementation (declared `onSuccess` + wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the + doubled channel; ⛔ no `precedence` contract field. + + The measured static-knowability partition: + + - **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is + runtime-only in general (`target` names an opaque registry entry; + `HookBodySchema` declares no return contract) — but `opensInNewTab: true` is + a schema-visible declaration of the handler-redirect channel (its contract is + "pre-open a tab, then drive it to the handler's returned `redirectUrl`"). + A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true` + is now **rejected at parse time**, with guidance naming both channels and the + remedy. Previously the pair parsed clean and one declaration was silently + dead at render. + - **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a + handler that actually returns `{ redirectUrl }` while the action declares + `onSuccess` — now logs a loud `[action-contract]` warning at both dispatch + surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming + the action, both channels, the interim winner and the remedy. Observe-only: + the wire is untouched and the interim renderer precedence stands until the + author takes the remedy. + + Single-channel declarations are untouched and pinned byte-identically: only + `onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and + `opensInNewTab: false` beside `onSuccess` all parse exactly as before. The + corpus was measured at zero doubled producers (this repo's examples and + platform metadata, objectui metadata, and the cloud SSO handoff producers per + the #11519 measurement), so no shipped metadata is affected. + + **Migration.** An action refused by the new refine must pick its one + destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning + `redirectUrl` from the handler), or keep `opensInNewTab` + the handler + redirect and drop `onSuccess`. Which channel is right is an authoring decision + the metadata cannot make for you, and zero such actions exist in any measured + corpus. + + +- 2d4fa75: feat(spec): enforce the documented `newTabUrl` / `opensInNewTab` co-constraint on `ActionSchema` (#11842) + + **BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + `newTabUrl`'s doc has always said "Only valid together with `opensInNewTab`", + and every renderer read point agrees: objectui's pre-opened-tab wrapper reads + the key only behind `action.opensInNewTab && newTabUrl`, and no other path + reads it at all. Nothing on the refine chain enforced the pairing, so an + action declaring `newTabUrl` without `opensInNewTab: true` parsed clean and + the key was silently inert — the ADR-0078 declared-but-unenforced shape, + arriving through a documented co-constraint rather than a missing key. + + `ActionSchema` now **rejects at parse time** an action declaring `newTabUrl` + whose `opensInNewTab` is not `true`, with guidance naming the pre-opened-tab + contract and both remedies (declare the flag if a pre-opened tab is intended; + otherwise delete the inert key — behavior is unchanged either way it was + already behaving, because the lone key was never read). An explicit + `opensInNewTab: false` beside `newTabUrl` is refused too, deliberately: + unlike the #11519 doubled-channel rule, `newTabUrl` has no meaning outside + the pre-opened-tab flow, so a declared-off channel leaves the key exactly as + dead as an undeclared one. + + The legal pairing is untouched and pinned byte-identically: `opensInNewTab: + true` + `newTabUrl`, `opensInNewTab` alone, and `opensInNewTab: false` alone + all parse exactly as before. The corpus was measured at zero lone-`newTabUrl` + producers (this repo's examples and platform metadata, objectui's fixtures + and renderer read points, and the cloud SSO producers, which declare the pair + correctly — re-measured at claim per the triage requirement), so no shipped + metadata is affected. + + +- 0e4e51b: feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992) + + + + The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE + carry-over contract instead of a rendering convention: a param may state, in + metadata, that its value is carried through the action dialog rather than + collected from the user. + + - `carryOver: true` — seed from the current row (`defaultFromRow: true` is + required alongside, enforced at parse time), render as a NON-EDITABLE + summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer, + which omits the param from the submission entirely — a carry-over param is + always sent. + - Aliases: `readonly` / `disabled` are refused with guidance naming + `carryOver` (a field's `readonly` means write-path strip, which is exactly + the wrong half here). + - Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set` + JSON facet params (`object_permissions`, `field_permissions`, + `system_permissions`, `row_level_security`, `tab_permissions`) declare it, + so the sanctioned clone path stops offering five prefilled raw-JSON + textareas an admin could hand-mangle into a clone that grants MORE than its + base. `description` stays an ordinary editable param. The send-side contract + is unchanged (#11703 pin 6 stays green). + + The objectui renderer leg (honouring the declaration in `ActionParamDialog`) + is the downstream card tracked on #11753. +- 40a93b5: feat(spec): retire the authorable `AdvancedPluginLifecycleConfig` surface; the lifecycle classes stay as a host-driven library (#11825, ADR-0049) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry + `advanced-plugin-lifecycle-config-retired` — where `os migrate meta` users + will look). + + `AdvancedPluginLifecycleConfigSchema` aggregated six lifecycle config groups — + `health`, `hotReload`, `degradation`, `updates`, `resources`, + `observability` — and NO group had a runtime reader, re-measured per group at + the retirement's base commit with positive controls: the kernel never + constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit + tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY + to the classes, never through this container); the `degradation` / `updates` + / `resources` / `observability` keys have no implementation body at all; and + no manifest, stack collection or metadata-type binding ever embedded the + container, so no authored document could carry it. An author declaring + `health: {...}` or `rollback: { automatic: true }` got a clean parse and + NOTHING — the #3950 shape at container scale, sharpened by production-safety + vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) + an AI author reads as proof the capability exists. + + FROM → TO: + + - `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / + `AdvancedPluginLifecycleConfigParsed` → *(removed — no replacement + container)*. The supported lifecycle surface is the host-driven library in + `@objectstack/core`: construct `PluginHealthMonitor` and pass a + `PluginHealthCheck`, construct `HotReloadManager` and pass a + `HotReloadConfig` (the `content/docs/protocol/kernel/lifecycle.mdx` + examples, #11811, are the supported usage). + - `GracefulDegradationSchema` / `GracefulDegradation` / + `GracefulDegradationParsed` and `PluginUpdateStrategySchema` / + `PluginUpdateStrategy` / `PluginUpdateStrategyParsed` → *(removed)* — value + schemas whose only consumer was the retired container; no implementation + body exists for any of their keys. They return only via the ENFORCE route + of ADR-0049 through a new ADR — the executor first, the vocabulary second. + + One-line fix: delete the config object (it configured nothing); if you drive + the library classes yourself, keep passing `PluginHealthCheck` / + `HotReloadConfig` directly — those vocabularies survive unchanged. + + The retirement kit: + + - whole-def deletion (route 3 — no carrier key, no authored document, so no + tombstone and no D2 conversion; the #8715 `identity/ApiKey` shape): + `kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`, + `kernel/PluginUpdateStrategy` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 + semantic entry `advanced-plugin-lifecycle-config-retired` + - pin test (`kernel/plugin-lifecycle-advanced-retirement.test.ts`): zero + holders for all 9 retired names on every public entry, survivors pinned + (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, + `HotReloadConfig` + `DistributedStateConfig`, `PluginStateSnapshot` — the + kept host-driven library vocabularies) + - zero authored occurrences in objectstack or objectui (measured at + dispatch, re-verified per group), so no in-repo source changes ride along; + `@objectstack/core` classes and tests are untouched and stay green +- 277948f: feat(spec,plugin-approvals): `escalation.enabled` defaults to `true` and the SLA sweep finally reads it (#12278) + + **BREAKING** semantic default flip on a published authorable key, shipped as + `minor` under the repo's launch-window convention for breaking changes. + Maintainer ruling 2026-08-27 (Option C), explicitly reversing the 2026-08-26 + "spec stays as declared" ruling with fresh analysis. + + `ApprovalEscalationSchema.enabled` declared `default(false)` while the + plugin-approvals escalation sweep never read the key: any escalation block + with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` + that silently approved requests their author had declared off the clock — + the ADR-0049 declared-but-unenforced shape, failing open. Worse, the + approval-node executor parses node config through the schema before + snapshotting it onto the request row, so the old default **materialized** + `enabled: false` into storage for every author who omitted the key, making + "authored off" and "defaulted off" byte-identical at the sweep site. + + One change, both halves: + + - **spec**: `enabled` now defaults to `true` (stays `z.boolean()`; no + tri-state). The feature-level switch is whether an `escalation` block + exists at all; within a block carrying `timeoutHours`, escalation is on + unless explicitly turned off — which is what the runtime, its eleven + behaviour tests, and every teaching surface have always meant. Declared in + `DEFAULT_CHANGES_BY_MAJOR` (17) and registered as the + `approval-escalation-enabled-default-flip` semantic migration entry. + - **runtime**: `runEscalations` skips a request whose snapshot carries an + explicit `escalation.enabled === false` — the declared switch is enforced. + Request snapshots created **before** the flip cutoff + (`ESCALATION_ENABLED_FLIP_CUTOFF_MS`, 2026-08-28T00:00:00Z) ride a + read-side legacy window and keep escalating exactly as they do today: their + stored `false` is overwhelmingly the old schema default materialized onto an + author who never wrote the key, every such stored row is escalating today, + and the window retires itself as pending requests drain — zero tenant rows + rewritten. + + Deployed metadata that omits `enabled` does not change behaviour (it + escalated before, it escalates after). What changes is that writing + `enabled: false` finally binds for newly opened requests — the declared + intent being honoured. To keep an SLA off, write `enabled: false`; to + escalate on timeout, an `escalation` block with `timeoutHours` is enough. + + +- 4f24e9d: feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + + **BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + + - New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. + - **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. + - `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. + - Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. + - `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. + - The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. + - `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + +- 7a25e7d: feat(spec): retire the orphan `CLICommandContributionSchema` export — the manifest surface it described is a tombstone (#12007, ADR-0049) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` + (`kernel/CLICommandContribution`) + the D3 semantic entry + `cli-command-contribution-retired` — where `os migrate meta` users will + look). + + `CLICommandContributionSchema` described a "CLI Command Contribution + declaration in the manifest" and claimed to be "retained for backward + compatibility and for describing command metadata in plugin manifests" — but + after #10724 tombstoned `manifest.contributes.commands`, no manifest surface + could legally carry these entries: the exported schema advertised a shape + whose only declared carrier rejects it. The manifest never referenced this + schema even before the tombstone (its inline `commands` item schema was an + independent duplicate), and it had zero consumers outside spec's own test and + generated artifacts, measured at the retirement's base commit with positive + controls in objectstack, objectui (pinned sha) and cloud — the exported + orphan-value-schema class (#3950: an exported schema with no consumer reads + as a capability). + + FROM → TO: + + - `CLICommandContributionSchema` / `CLICommandContribution` → *(removed — no + declarative replacement, because no declarative surface ever carried it)*. + CLI commands are registered through oclif's native plugin discovery: the + plugin package declares an `oclif` section in its own `package.json` — + `OclifPluginConfigSchema` / `OclifPluginConfig` (same module) describe that + live surface and survive unchanged. + + One-line fix: delete the import (nothing ever read the declaration); if you + describe a plugin's CLI commands, declare the `oclif` section in the plugin's + `package.json` — `OclifPluginConfigSchema` validates it. + + The retirement kit: + + - whole-def deletion (route 3 — no carrier key, no authored document, so no + tombstone and no D2 conversion; the #11825 / #8715 shape): + `kernel/CLICommandContribution` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 + semantic entry `cli-command-contribution-retired` + - pin test (`kernel/cli-command-contribution-retirement.test.ts`): zero + holders for both retired names on every public entry, survivors pinned + (`OclifPluginConfigSchema` / `OclifPluginConfig` — the live `package.json` + `oclif` surface) + - the module docblock's Commander.js migration prose is KEPT — it is cited by + the `contributes.commands` tombstone (full-file deletion was explicitly not + the shape) + - zero authored occurrences in objectstack, objectui or cloud (measured at + dispatch, re-verified at claim), so no in-repo source changes ride along +- 4f6325d: feat(spec): `IDataEngine`'s datasource-def contract catches up to the engine — `registerDatasourceDef` accepts `external.credentialsRef`, and `listDatasourceDefs` is declared (#12805) + + Additive contract catch-up to declared-and-enforced engine reality (#12758), + the fourth datasource-lifecycle member under the 2026-08-25 #11833 ruling's + item-4 precedent. `registerDatasourceDef`'s parameter now admits the + `external.credentialsRef` secrets-store handle the engine has accepted and + retained since #12758 — before this, a caller typed against the published + contract was refused with TS2353 at the consumer seam for a value the runtime + keeps (the engine's own typecheck was green either way, parameter bivariance). + The new optional `listDatasourceDefs?(): EngineDatasourceDef[]` member is the + read-back of the same registry, declared so a `sys_secret` reference sweep + (#12804) can ask the engine "which code-declared datasources hold a handle" + through the `'data'` slot contract instead of naming the engine class or + re-declaring a consumer-local structural type (the #11833 pattern). Both + members share the newly exported `EngineDatasourceDef` shape and stay + optional — only engines that own a datasource registry answer. No runtime + change; existing `IDataEngine` implementers and callers are unaffected. +- 52954c0: `IDataEngine` declares the optional `syncObjectSchema?(objectName: string): Promise` member (#12482) — on-demand single-object physical schema sync: create/alter the object's table, or for a federated (external) object register its DDL-free read metadata (ADR-0015 §18). Additive contract catch-up under the 2026-08-25 #11833 ruling's item-4 precedent as executed by #12248: the member #12010's inventory left "not verified" is verified — implemented on `ObjectQL`, consumed cross-package by two service packages, both until now through consumer-local structural recovery (`service-datasource`'s `ConnectionEngineLike.syncObjectSchema?`, called per bound external object after its driver connects; `service-messaging`'s system-table provisioning via an `as unknown as` cast whose own comment recorded the member "lives on the concrete ObjectQL engine, not the contract"). FROM undeclared (consumers cast or re-declare structurally) TO declared-optional on `IDataEngine` (consumers read `engine.syncObjectSchema` directly and keep their runtime probes). `service-messaging` drops the now-redundant cast (behaviour unchanged). Optional, so existing `IDataEngine` implementers and test doubles are unaffected. No runtime change. +- daae7aa: Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) + + Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real + contracts. Their shapes were always stable and server-produced — they were declared inline on the + implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no + spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` + exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: + no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled + as part of this change). + + **`@objectstack/spec` (additive):** + + - `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, + `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: + `{ query, hits, totalObjects, totalHits, truncated }` with hits of + `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / + `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` + (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap + #8140 left a compile-time guard against, and the guard stands unchanged. + - `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body + of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s + structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). + No `droppedFields` member — unlike `createData`, the clone producer emits none. + + **`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is + now `Promise` and `data.clone` is `clone(…): Promise>` + (a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing + changes at runtime — no request, response, unwrapping or error path is touched — but code that + compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated + types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now + refused at compile time, which is the trap the erasure used to hide. + + The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to + the decision inbox with a consumer-survey reading per the same ruling. +- 3b4c56c: fix(objectql,spec): run the pre-delete reference check under the system identity (#12166) + + **Grade: `minor`, not `patch` — argued, because a permission-behaviour change + should not arrive as a bug-fix bump.** Configurations that returned `403` now + return `200`. Nothing gets more restrictive and no API changes shape, so this + is not `major`; but "records a role could never delete are now deletable" is a + security-surface accept-set change an upgrader must be able to see in a + release-notes heading, and a `patch` line is exactly where it would not be + looked for. The spec half ships `minor` alongside because the message catalog + gains two keys. + + Deleting a record runs the platform's pre-delete reference check, which issues + a `find` against every referencing object. That probe ran as the **calling + operator**, so a caller with full delete rights on the target but no read grant + on any referencing object got a blanket `403 PERMISSION_DENIED` — regardless of + whether a reference actually existed. An **empty** referencing table 403'd too. + The reporting deployment (`@objectstack/*@17.2.0`) measured 17 role×object + pairs where the UI shows a delete button that always fails, with the A/B + control that granting read-only on the referencing object — touching *nothing* + about delete rights — turned the identical operation into a `200`. + + It silently made "delete permission" mean "delete **plus read on every + referencing table**", a coupling invisible in the permission UI and impossible + for an administrator to self-diagnose: the refusal said only "You do not have + permission to perform this action." + + Referential-integrity actions are engine responsibility executed under system + identity on every mainstream platform — the RDBMS FK baseline, Salesforce + (lookup clearing and cascade delete documented as bypassing sharing), + Dataverse, ServiceNow, Odoo. Caller identity here was the outlier. Maintainer + ruling 2026-08-26, option A. + + **What changed.** The dependents probe now runs `sudo()`-shaped — + `{ ...context, isSystem: true }`, following the in-repo precedent in + `packages/objectql/src/integrity/dangling-reference-audit.ts`. The spread is + load-bearing: the caller's open transaction handle, **tenant scope** and + `userId` all survive, so the probe does not leave the caller's transaction and + does not read across the tenant wall. + + **What did NOT change.** Only the reference *check* switches identity. The + caller's own delete authorisation on the target is untouched; the `set_null` + `UPDATE`, the `cascade` `DELETE` and the target's own delete all still run as + the caller. A caller without delete rights on the target is refused exactly as + before — pinned in both directions, because a relaxation must not become a + hole. + + **Refusal copy.** Because the probe now sees rows the caller may hold no read + grant on, `DELETE_RESTRICTED` discloses the dependent **count** only when the + caller's own identity would have produced the same rows (compared on row + identity, so row-level narrowing counts too). Otherwise the count is withheld + and the refusal renders one of two new catalog keys, + `delete_restricted_opaque` / `delete_restricted_required_opaque` — the same + sentences minus `{{count}}`, in all four bundled locales. Without that, the + refusal would be an exact, repeatable cardinality oracle over a table the + caller may not read. The referenced **object** and the relation field are named + either way: those are declared metadata, and they are the whole of what makes + the refusal self-diagnosable. `dependentCount` is **absent** rather than `0` in + the withheld case — `0` would be a false statement about the rows. + + **Audit.** The elevation is filed with both halves, the Salesforce/Dataverse + ledger shape: `triggeredBy` = the deleting operator, `executedAs: 'system'`, + plus the referenced object and relation field — never a row id, value or count. + It is filed *before* the probe, so a refused or failed check is recorded too. + Declared limit: this is an engine **log** record, not a `sys_audit_log` row — + the elevated operation is a read, and `plugin-audit`'s read writer declares and + pins that a system-elevated read produces no row. A durable row belongs to the + plugin that owns that shape. + + **Upgrade note.** If a deployment was relying on the `403` as a de-facto delete + gate, that gate is gone. Under the industry baseline such usage is itself + non-standard and should be expressed as an explicit `deleteBehavior: 'restrict'` + on the relationship rather than as a read-permission side effect. No such + reliance was measured; the ruling records this as a known confidence gap. +- 7345308: feat(spec): retire the `element:form` element at element grain (#9249, 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). + + `element:form` never had a renderer or reader anywhere — the #9220 + (`element:filter`) shape one element over, recorded by that card's own verdict + sweep. Measured at retirement (objectstack `dd84ddd796`, objectui `3ece13e33`; + cloud per the origin card's two recorded readings at `5f1bf23f` and `a11458b`, + positive controls passing): objectui registers no renderer for it — its + `renderers/basic/elements.tsx` header deferred the element to "owning plugins" + that never materialized — and Studio's designer palette carries it as a + no-renderer `PALETTE_EXCLUSIONS` entry that names the live replacement ("no + renderer — use the object-bound `object-form` block"). The 2026-06 + page-liveness audit already recorded it rendering "Unknown component type". So + the full form contract `ElementFormPropsSchema` declared — `object`, `fields`, + `mode`, `submitLabel`, `onSubmit` (CEL), `aria` — was a capability claim + nothing kept. + + **What is refused:** every authored `element:form` key. All six are + `retiredKey()` tombstones, so authoring one is a `tsc` error and a parse error + carrying the prescription (fully-qualified key, why the element is dead, the + replacement, the `os migrate meta` pointer), dispatched through the KEPT + `ComponentPropsMap` row — deleting the row would demote the type to an + unregistered custom string the #5068 props gate deliberately skips. + + **What stays accepted:** a bare `element:form` node with empty `properties` + (the migrated shape — the open `type` union accepts any string, and deleting + authored page nodes is a layout decision a mechanical conversion must not + make). It renders nothing, exactly as it always did. + + The retirement kit: + + - `retiredKey()` tombstones for all six keys at the schema + (`packages/spec/src/ui/component.zod.ts`); the `PageComponentType` enum + drops the value (de-advertisement — the open string arm still accepts it) + - ADR-0087 registration: six retired-key entries + `ui/ElementFormProps:{aria,fields,mode,object,onSubmit,submitLabel}` and the + D2 conversion `element-form-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips all six keys from authored + `element:form` blocks (pure lossless deletes; none ever had an effect to + lose) and leaves the bare node + - pin tests (`component.test.ts` — refusal pins assert the prescription per + key; a positive pin parses the bare migrated node clean and asserts nothing + materializes; the kept-map-row pin flips from parse to refusal) + - `packages/lint`'s `COMPONENT_FIELD_SPECS` entry drops (its own changeset); + the component-translation `submitLabel` describes stop naming `element:form` + as carrier (the orphaned-key decision is #10926, out of this card's scope) + - generated baselines/docs follow the schema (authorable surface/defaults, + spec-changes, upgrade guide, reference docs) + - objectui's `elements.tsx` comment and Studio palette-exclusion lines are + cross-repo and already queued under objectui#4935 + + ## FROM → TO + + ```ts + // before — parsed green; nothing anywhere rendered it, so the page showed + // "Unknown component type" where the author expected a form + { + type: 'element:form', + properties: { + object: 'lead', + fields: ['name', 'email'], + mode: 'create', + submitLabel: 'Create Lead', + }, + } + + // after — use the object-bound `object-form` block (#7751): rendered, + // designer-publishable, same intent (`objectName`, `fields`, `mode`, + // `submitText`) + { + type: 'object-form', + properties: { + objectName: 'lead', + fields: ['name', 'email'], + mode: 'create', + submitText: 'Create Lead', + }, + } + ``` +- 30d96ab: The engine-registration road into datasource introspection now meets the compiler (#11493, extending the #11123 ruling from the `DatasourceDriverHandle` seam): `IDataDriver` gains an optional `introspectSchema?(): Promise` member, and `IDataEngine` gains an optional `introspectDatasource?(datasource: string): Promise` member. Both are typed with the spec's one introspection shape (`IntrospectedSchema`, `@objectstack/spec/contracts`). Drivers and engines without introspection stay conformant — the members are optional — while a driver that DOES implement `introspectSchema` with a mis-shaped result (a column flag spelled `isPrimary`, a bare `{ tables }` with no `dialect`/`introspectedAt`) now fails compile at the offending field instead of surfacing at runtime as a federated table whose records cannot be located. +- f658793: Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须 + schema-free」) for `@objectstack/core`'s plural→singular store-key fold. + + `@objectstack/spec`: the `defineStack()` manifest-collection vocabulary + (`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`, + `singularToPlural`) moved to a schema-free module and is now ALSO exported + from the sanctioned schema-free entry `@objectstack/spec/meta-spelling` + (widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared` + keeps the same four symbols as re-exports — no consumer-visible removal. The + manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts + (#8424). + + `@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves + from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so + browser consumers of `@objectstack/core` (every `@objectstack/client` bundle) + no longer link the zod schema closure through the store-key fold. +- c95ad19: Close the entry-nameability invariant on the four remaining public entries that leaked it (#12414). Generalizing #11350/#11709's `defineStack`-only probe to every public entry found the class was never fully closed: seven (entry, type) pairs across four entries still had a `define*` factory's return type expand to mention a type the entry itself did not re-export — `Book`, `FormField`, `NavigationItem` from the root `@objectstack/spec` entry, `UnknownAuthoringKeyFinding` from `@objectstack/spec/kernel`, `FilterCondition` and `StateNodeConfig` from `@objectstack/spec/ai`, and `FilterCondition` from `@objectstack/spec/ui`. Each is repaired the same way as the prior two rounds: one re-export line on the leaking entry, from the module that already declares the type as public on its own subpath. A consumer writing an un-annotated `export const x = defineBook(...)` (or the `/ui`, `/ai`, `/kernel` equivalent) previously hit TS2883 naming an unaddressable hash-named internal dist chunk and could not build; all seven now declaration-emit cleanly with no annotation required. No runtime change. `packages/spec/entry-nameability.baseline.json`, the shrink-only ledger `check:entry-nameability` (#11986) reads, is now empty — the class it was tracking is closed. +- 5997207: feat(spec): materialize `deleteBehavior` only on reference field types (#9784) + + `FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default + onto non-reference field types (`text` / `datetime` / `number` / every other + non-relational type, `user` included). The key has no meaning there — the + engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` / + `lookup` fields carrying a `reference` — yet the materialized default shipped + in every built app artifact, where a parse-time default becomes an apparent + explicit declaration downstream (the #4447 shadowing mechanism) and reads as + meaningful to AI authors browsing the artifact. + + What changes and what does not: + + - **Bare non-reference fields** parse to output that **omits** `deleteBehavior` + (previously: `deleteBehavior: 'set_null'` materialized on every type). Built + artifacts thin accordingly — measured on the showcase app: 210 fields, the + key drops from 206 fields to 16. + - **`lookup` and `tree`** keep materializing `set_null` byte-identically, at + shape position. + - **`master_detail`** keeps omitting it (the #9689 idempotent-materialization + ruling, unchanged). + - **The accept-set is untouched**: an authored `deleteBehavior` on any field + type parses exactly as before and round-trips verbatim, so artifacts built + by earlier versions (which carry the materialized key on every field) remain + fully legal inputs. `parse(parse(x))` holds across the boundary. + + No authored metadata needs any change: no key is removed, renamed or + re-shaped, and no authoring spelling that parsed before is refused now. +- f90e820: feat(spec): register `FLOW_INPUT_SCHEMA_INVALID` — the definition-level input-schema refusal becomes a never-dispatched exit with its own ADR-0112 code (#11504, the contract half of the #10025 ruling) + + `AutomationResult.code` gains `'FLOW_INPUT_SCHEMA_INVALID'`, and the code is + registered in the ADR-0112 error-code ledger under `@objectstack/runtime` + beside `FLOW_DISABLED` / `FLOW_NO_START_NODE`. Semantics: a node's static + `config` violates the `inputSchema` its own flow definition declares, so the + engine refuses to dispatch — nothing runs, nothing is written, the result + carries the code and NO `status` (the #9378 never-dispatched class), and a + transport maps it to **422** (unexecutable stored definition, exactly as + `FLOW_NO_START_NODE`). + + Ruled by #10025 (maintainer, 2026-08-20): the refusal is **non-retryable** — + the guard's verdict is a pure function of the flow definition, so re-running + it cannot change the answer. This release ships only the contract vocabulary; + the engine behaviour change is #10025's services half and lands separately. + + **Operator-visible consequence once that services half lands, stated + plainly:** retry accounting and run-log volume change for affected flows. A + `strategy: 'retry'` flow whose node `config` violates its declared + `inputSchema` today burns its whole retry budget (including configured + backoff delays) and writes `1 + maxRetries` identical failed run-log rows; + after the services half it refuses **once**, producing **one** run-log row + carrying `code: 'FLOW_INPUT_SCHEMA_INVALID'` and no `status`. Anything + watching retry counters or paging run history for this exit sees different + numbers for the same flow. +- e8bd715: **i18n:** the screen-flow copy vocabulary gains its resolver family — `translateFlow`, `resolveFlowScreenTitle`, and the `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` shared key lists (#7646 recommendation B, the spec half of #11287). + + #7763 declared `TranslationData.flows` (`flows..label`, `.screens..title`, `.screens..fields..{label,placeholder}`) and deliberately left the resolver half unwritten because another change was in flight on `i18n-resolver.ts`. This lands that half in `packages/spec/src/system/i18n-resolver.ts`, mirroring the page family's conventions: + + - `translateFlow(flow, bundle, opts)` — the metadata-document overlay: translates the flow's own `label` and, for every `type: 'screen'` node with an id, the screen heading (`config.title` — written even when the author relied on the node-label fallback, since the executor builds the wire title as `config.title ?? node.label`) and per-field `label` / `placeholder`, key by key across the locale chain with the authored source strings as fallback. Input not mutated; off-spec bundle keys the schema refuses (`description`, `help`) are ignored, never overlaid. + - `resolveFlowScreenTitle(bundle, flowName, screen, opts)` — the piecemeal half for a caller already holding a `ScreenSpec` (`nodeId` addressing, `title` literal fallback). + - `FLOW_SCREEN_COPY_KEYS` (`['title']`) and `FLOW_SCREEN_FIELD_COPY_KEYS` (`['label', 'placeholder']`) — the one list the resolver overlay and the CLI's skeleton extractor (downstream card) both import, pinned against `TranslationDataSchema` so neither can drift. + - Supporting shapes: `FlowLike`, `FlowNodeLike`, `FlowScreenLike`, `FlowScreenFieldLike`, `FlowScreenCopyKey`, `FlowScreenFieldCopyKey`. + + Additive surface widening only — no existing shape changes meaning, and `translateFlow` is deliberately **not** registered in `translateMetadataDocument`'s dispatch table: that registration reaches the REST metadata boundary by itself (`TRANSLATABLE_METADATA_TYPES` drives `@objectstack/rest`), which would stand up a shipped reader of the `flows` group while its liveness ledger rows are `planned`. The runner application (server or client side), the CLI coverage bucket, and the ledger flip to `live` all ride the downstream cards of #11287. +- daeaaf9: Form-view predicates loudly reject the `features.*` scope root (objectstack#12665; ruled 2026-08-27 on objectui#6262, option B — vocabulary narrowing). One authored form view is served on two kinds of route, and a `features.*` predicate got two verdicts from the same text: inside an app (`/apps/:appName/*`) the root resolves against the real auth-config flags, while on the standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, the root is unbound, the predicate faults — and `visibleWhen`'s fault fallback is visible, so the field or section a feature flag was meant to hide was shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch: zero authored `features.*` form-view predicates exist across objectui apps/examples/content (18-hit positive control on authored `visibleWhen` generally), so the vocabulary is narrowed at the authoring door instead of building auth-config parity machinery for zero consumers. + + What newly gets rejected: any form-view predicate naming `features` in root position — dotted member access (`features.x`), index access (`features['x']`), or the bare root — outside string literals; on section-level `visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option `visibleWhen` authored inline in the form view, including the deprecated `visibleOn` alias spellings and the flattened runtime form overlay. The refusal is a prescriptive parse issue naming the root, the surface, the fail-open reason and the ruling. Member access on a record field that happens to be named `features` (`record.features.x`) stays legal, as does every other root; app-context predicate surfaces (page components, actions, bulk-action eligibility) keep `features.*` exactly as before. + + +- c459da6: feat(spec): narrow the per-option `default` key OUT of the form-view options vocabulary — the object-field face keeps it enforced (#12868) + + **BREAKING** accept-set narrowing on the published FormView vocabulary, shipped + as `minor` under the repo's launch-window convention for breaking changes. + + + + `SelectOptionSchema` serves two surfaces, and only one of them reads the + per-option `default` key: + + - **Object-field options** (`Field.select.options`): ENFORCED and UNTOUCHED — + `applyFieldDefaults` falls back to the option marked `default: true` when the + field declares no `defaultValue`, `defaultValue` wins when both are declared, + and the alias rows (`isDefault`/`selected` → `default`) stay. + - **Form-view options** (`FormFieldSchema.options` inside a FormView): the same + key parsed clean and nothing read it — the engine's insert-path fallback + consults the OBJECT definition's options, never a form view's, and no form + renderer seeds a value from it. The maintainer-ruled disposition (2026-08-28, + disposition 甲) narrows the key out of this face only. The ruled census + measured ZERO occurrences of `default` (and the alias spellings + `isDefault`/`selected`) inside form-view options across the tree, the example + apps and the published `*.form.ts` corpus, with the instrument's positive + control hitting the enforced object-field usages. + + FROM → TO, and the one-line fix: + + - FROM: `{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open', default: true }] }` + inside a form view's `sections[].fields[]` (or nested `fields`) — parsed + clean, did nothing. + - TO: delete the key from the form-view option. Declare the pre-selected choice + on the OBJECT definition instead — field-level `defaultValue`, or + `default: true` on that field's own `options` entry (both enforced there). + - The new `FormSelectOptionSchema` (an Omit-derivation of `SelectOptionSchema` + minus exactly `default`) refuses the key with a tombstone prescription + carrying this mapping; `isDefault`/`selected` get guidance pointing at the + object definition instead of a rename toward a key the shape refuses. + - The protocol-18 conversion `form-view-option-default-removed` strips the key + from stored sources (pure lossless delete — it never had an effect on this + surface to lose). Run `os migrate meta --from 17` to list the mechanical + edits for existing sources; apply them by hand. +- e914733: `FormFieldSchema`'s constraint keys converge on the value shapes the object-field surface landed (#12174): `maxLength`/`minLength` tighten from bare `z.number()` to `z.number().int().min(1)` (the #11566/#11949 template — a character length is a positive integer, and `minLength: 0` is a vacuous always-true declaration refused loudly; "no minimum" is expressed by omitting the key), and `precision`/`scale` tighten to `z.number().int().min(0)` (the #8321 template — digit counts). `min`/`max` are unchanged: they are values, not counts, exactly as on the object-field surface. + + The row keys are live, measured in objectui: the spec bridge (`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy all six onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (objectui#5595), the fields package builds validation rules from `minLength`/`maxLength`/`min`/`max`, and `precision`/`scale` drive the number widgets — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and `GET /forms/:slug` serves the rows verbatim to anonymous renderers. What newly gets rejected: `0` (length pair only), negative and non-integer values on the four count-shaped keys. Unlike the object-field twins there is no type-conditional applicability gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable here, key placement stays the object field's own schema's job. Already-legal declarations round-trip byte-identically, and absence stays absence — no default materializes. + + +- 4635f3e: fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) + + + + **BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 + cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — + `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-inert-state-strategies-retired` — where `os migrate meta` users + will look). + + This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 + ruling deliberately kept. That ruling retired the authorable lifecycle-config + container and kept `HotReloadConfigSchema` as a host-driven library parameter + type; this change measures the kept vocabulary's own remainder and finds the + same defect in it. The keep itself stands — `HotReloadConfigSchema`, + `PluginStateSnapshotSchema` and the health vocabularies still export, and + `HotReloadManager` / `PluginHealthMonitor` are untouched. + + The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both + wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said + "memory fallback" — and announced the substitution at DEBUG level only. A host + that asked for durable or cluster-replicated state got process-local memory + and no error: state that does not survive the restart it was configured to + survive. `distributedConfig` had ZERO readers anywhere, so an author could + name a Redis endpoint, a TTL and a replication factor and nothing ever opened + a connection. + + FROM → TO: + + - `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime + behaviour, because `'disk'` already stored to memory. It is the spelling + that was false, not the behaviour. + - `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or + `'none'` to disable state preservation outright. + - `distributedConfig: { … }` → *(removed)* — delete the key. It left with the + `'distributed'` value its own doc comment called it "required" for. + - `DistributedStateConfigSchema` / `DistributedStateConfig` / + `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of + that one key. + + One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete + any `distributedConfig` — you were already getting in-memory state. There is + no in-tree replacement for durable or distributed plugin state; persist it in + the host, which owns the process lifetime these strategies pretended to + outlive. Real disk or distributed persistence returns only via the ENFORCE + route of ADR-0049 — the implementation first, the declaration with it. + + The retirement kit: + + - **enum-value narrowing** (`['memory','disk','distributed','none']` → + `['memory','none']`): invisible to all four ratchets by construction (the + def still emits), so the prescription hangs on the enum's own `error` map + dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` + precedent. A value that was never legal still gets zod's own enum message, + so a typo is not told it "was removed". + - **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable + surface: no metadata-type binding, stack collection or manifest embed ever + carried it, and nothing in the tree parses `HotReloadConfigSchema` outside + its own unit test, so there is no authored document to rewrite and nobody + who could receive a parse-time tombstone): `kernel/DistributedStateConfig` + in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as + a def removal must — `api-surface` −3, `authorable-surface` −8, + `json-schema.manifest` −1. + - **runtime doors** in `@objectstack/core`, because route 3 leaves no + parse-time prescription: `HotReloadManager.registerPlugin` now refuses an + unhonoured `stateStrategy` and a leftover `distributedConfig` with an + ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the + prescription. Refused BEFORE the `enabled` check, so a disabled config + cannot smuggle the false declaration through. TypeScript hosts never reach + it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a + compile error at the call site. + - **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the + #11825 survivor list, so this reverses one line of that ruling on new + evidence — #11825 measured the container's six groups, never this key's own + readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` + moves in the same commit with the reasoning recorded beside it, and asserts + the surrounding keep is intact. + - zero in-tree consumers passed `'disk'` or `'distributed'` (measured at + cdbd9204b6 with a firing positive control; every live caller passes + `'memory'` or `'none'`), so no in-repo source changes ride along. +- ee3595c: fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-watch-placeholder-retired` — where `os migrate meta` users will + look). Graded `minor` rather than `major` for the same reason #12340 was one + day earlier, in this same module. + + ADR-0049 applied one symbol over from #12340, in the same file and on the same + per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and + `PluginStateSnapshotSchema` still export, and `HotReloadManager` / + `PluginHealthMonitor` are untouched apart from the two doors below. + + `HotReloadManager.startWatching` contained **no watcher**. Its whole body was a + guard plus `logger.info('File watching started', { patterns })`, above an + in-source note saying real watching "would require chokidar or similar". Where + #12340's inert fallback at least announced itself at DEBUG, this claimed + success at **INFO**: an operator who set `enabled: true` with `watchPatterns` + and read that line had been told the opposite of the truth. `watchHandles` was + only ever read, deleted, iterated and cleared and **never set**, so + `stopWatching`'s cleanup branch and the teardown loop over its keys were + structurally unreachable rather than merely untaken. `watchPatterns` therefore + had no reader that acted on it — its only two uses were log lines. + + FROM → TO: + + - `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your + globs wherever your own watcher reads them. + - `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`, + called from your own watcher's change handler. That is the debounced + integration point this class does implement, and it is unchanged. + + One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own + file watcher instead of `startWatching` — nothing was ever watched, so nothing + that used to happen stops happening. File watching is the host's job in this + host-driven library; `chokidar` is already a dependency of + `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — + never of `@objectstack/core` — so a host has a working model to copy. + + The retirement kit: + + - **key tombstone**, and the build is what chose it: the plain deletion was + tried first and `gen:schema` gate (a) refused it, because + `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a + silent strip (#3733, ADR-0104) — the very defect being retired, one layer + down. #12340 could take route 3 because what left there was a whole *def*; a + key leaving a *surviving* def has no such exit. So `watchPatterns` is + `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and + `kernel/HotReloadConfig:watchPatterns` is registered by exact key in + `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves + `authorable-surface` only — the def still emits, so `api-surface` and + `json-schema.manifest` do not. + - **no D2 conversion**, deliberately: the chain walks a normalized stack, and + `HotReloadConfig` is not an authorable surface — no metadata-type binding, + stack collection or manifest embed ever carried it — so a conversion would be + a transform with no seam that ever runs. For the same reason the prescription + carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling + in this module does not. + - **runtime doors** in `@objectstack/core`, because nothing in the tree parses + `HotReloadConfigSchema` outside its own unit test, so the tombstone alone + reaches nobody: `startWatching` now throws an ADR-0112 envelope + (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and + `registerPlugin` refuses a leftover `watchPatterns` the same way — before the + `enabled` check, so a disabled config cannot smuggle the false declaration + through. `startWatching` is kept as a throwing door rather than deleted so + that caller meets a prescription instead of a bare `TypeError`. + - **dead code removed with a firing positive control**: `watchHandles` and both + of its unreachable readers are gone. The zero was pinned first — + `reloadTimers.set` resolves a real writer in the same file and the same scan, + while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the + half that always did something (it cancels a pending debounced reload), and + `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles` + and therefore ran zero times. + - **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a + removal: no runtime composes `HotReloadManager`, so enforcing would build for + a caller that does not exist; and a scan of every planning doc returned zero + mentions of hot-reload file watching against 145 control hits in the same + files, so there is no roadmap for `experimental` to point at. +- 0c2334f: feat(spec): retire preview mode — the `'preview'` RuntimeMode value and the whole `KernelContext.previewMode` / `PreviewModeConfig` block (#11846, ADR-0049) + + + + **BREAKING** accept-set narrowing and export removal, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — + `RETIRED_KEYS_BY_MAJOR[18]` for both walked-shape keys, + `RETIRED_DEFS_BY_MAJOR[18]` for the def, plus the D3 semantic entry + `kernel-context-preview-mode-retired` — where `os migrate meta` users will + look). + + The declaration was the sharpest declared-≠-enforced shape on a SECURITY + surface: the schema promised "bypass auth, simulate admin identity" and named + a production guard "the runtime must enforce", and NO code path implemented + either half. Measured zero consumers in objectstack, objectui and cloud + (cloud#1651, closed 2026-08-26 with positive controls: `RuntimeMode` has zero + hits repo-wide there, and `ArtifactKernelFactory` — where preview auto-login + would live if anywhere — has 20+ hits and never touches `previewMode`; + re-verified in objectstack at dispatch, 2026-08-27). An author — very often + an AI — could write the six-key block per the reference docs, parse cleanly, + and get no behaviour and no diagnostic. + + FROM → TO: + + - `mode: 'preview'` → *(removed value)* — `mode` defaults to `'production'`; + use `'development'` for local demo work. The rejection carries the + prescription via the enum's own error map (the `HookBodyCapability` + precedent); every other mode keeps zod's own message. + - `previewMode: { … }` on `KernelContext` / `TenantRuntimeContext` → + *(removed key)* — tombstoned with `retiredKey()` (the schemas are not + `.strict()`, so a bare deletion would be a silent strip): authoring it is + now a `tsc` error and a parse error carrying the prescription. + - `PreviewModeConfigSchema` / `PreviewModeConfig` / `PreviewModeConfigParsed` + → *(removed — no replacement)*. The def described behaviour no layer + implemented; an exported value schema with no consumer reads as a + capability (#3950). + + One-line fix: delete the key and the value — neither ever changed runtime + behaviour, so removing them changes nothing observable. Preview deployment + ROUTING is untouched: `OS_PREVIEW_MODE` / `OS_PREVIEW_BASE_DOMAINS` keep + working exactly as documented (deployment routing, never identity). If a + preview experience becomes a product capability it re-declares fresh, with + the production-posture hard-refusal as the first-landed half (#11846 ruling + record, maintainer 2026-08-27). + + The retirement kit: + + - tombstones at both declarations (`kernel/KernelContext:previewMode` and the + `.extend()` copy `kernel/TenantRuntimeContext:previewMode`, both in + `RETIRED_KEYS_BY_MAJOR[18]`); the enum value's prescription on + `RuntimeMode`'s error map (enum-VALUE retirements register nothing in + RETIRED_KEYS_BY_MAJOR and leave the surface ratchets byte-identical) + - whole-def deletion `kernel/PreviewModeConfig` in `RETIRED_DEFS_BY_MAJOR[18]` + (manifest key deliberately removed; the #4725 gate adjudicated it) + - deliberately NO D2 conversion: a kernel context is constructed by host code + at boot — not a stack collection member, never a `sys_metadata` row — so + the conversion chain has no seam that would ever see one (the + `kernel/Manifest:loading` disposition); the D3 semantic entry carries the + prescription + - pin tests (`kernel/preview-mode-retirement.test.ts`): both rejection sites + flip from silent parse to the prescription; zero holders for all 3 retired + export names on every public entry; the carrier schemas survive +- d2619fd: `ApiEndpoint.target` is now **optional** in the vocabulary (#10338, maintainer ruling + 2026-08-23). The key was required on every endpoint but read only for `type: 'flow'` + (executor, OpenAPI enrichment and the publish gate all address an `object_operation` + via `objectParams.object` / `.operation`) — so an `object_operation` author was forced + to write a dead string nothing consumed or cross-checked. Authoring guidance: omit + `target` on `object_operation` endpoints. The publish gate still **requires** `target` + for `type: 'flow'` — a flow endpoint that names no target flow is refused at publish, + and the runtime's structural backstop answers `501 NOT_IMPLEMENTED` for one that + reached the store another way. No migration: this is a pure widening — every previously + valid declaration (all of which carry a string `target`) still parses unchanged. +- e5ce2ed: Packaged actions can be switched off, on the same activation ledger as flows + + A packaged action can now be disabled for an installation, generalizing the + packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on + the maintainer's amendment ruling 3). The flip writes an install-level row to + the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — + no new table, no new column, no schema change of any kind. Absence of a row + means the packaged default, active, so a deployment that never flips anything + behaves exactly as before, and an empty ledger changes nothing anywhere. + + The consult point is action DISPATCH, and it is present on every door that + dispatches a declared action: the REST `POST /actions/:object/:action` route and + the MCP `run_action` bridge. Both call one shared guard, and a disabled action + is refused `409 ACTION_DISABLED` before anything runs — before the handler body + (which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action + reaches the automation engine, before the param contract is enforced and before + the subject record is read. The refusal names the ledger and the remedies. The + code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and + answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, + which would tell an operator to go looking for a flow that does not exist. + + The consult reads a projection the ObjectQL engine holds and hydrates at boot, + so a disabled action stays disabled across a restart and across the handler + re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — + the ledger records the customer's choice, and nothing re-arms it silently). + + The write door is `POST /actions/_activation/:object/:action` with a + `{ enabled?: boolean }` body. Its first segment is reserved rather than deep in + the path because a machine name can never begin with `_`, so it cannot collide + with an object, an action or a record id. It carries the same two authority + tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture + rule — in the `group` and `isolated` postures the install-wide switch requires + the platform operator, while `single`, where install-level and org-level are the + same scope, is unchanged. That gate is now one implementation shared with + `POST /automation/:name/toggle`; the flow refusal text is unchanged. + + Two refusals are worth knowing about. The ledger addresses an action by its + machine name, so a name declared on more than one object is refused with + `409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off + silently. And a flip that cannot be made durable — no ledger table reachable — + is answered as a failure instead of a 200, because a switch reported as durable + that reverts on the next restart is the failure this whole family exists to + remove. + + Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so + disable is the only primitive here and authoring a new sibling action stays + exactly as it is today. +- be21955: feat(spec): retire the nine dead members of the plugin-manifest `contributes` block — `events` / `menus` / `themes` / `translations` / `actions` / `drivers` / `fieldTypes` / `functions` / `commands` (#10724, ADR-0049 enforce-or-remove) + + + + **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). + + The census behind it (#10627, controlled and re-verified at claim time across + objectstack, objectui and cloud) measured that the ENTIRE monorepo contains + exactly one non-test read of `manifest.contributes`, and it reads `kinds` + (`packages/objectql/src/engine.ts` → `registry.registerKind`). The other nine + members parsed, entered the manifest, and changed nothing — while published + material kept teaching them: `commands` documented Commander.js runtime + resolution the CLI dropped for oclif auto-discovery, `fieldTypes` advertised a + registration seam that has never existed, and `events` was decorative even for + its only in-repo author, which already subscribes imperatively. + + **What is refused:** authoring any of the nine keys. Each is a `retiredKey()` + tombstone (the `manifest.loading` precedent — neither `ManifestSchema` nor the + `contributes` object is `.strict()`, so a plain deletion would have silently + stripped the keys), so authoring one is a `tsc` error and a parse error + carrying the per-key prescription. + + **FROM → TO, per member** (each tombstone carries its own one-line fix): + + - `contributes.events` → subscribe in plugin code (`ctx.hook('kernel:ready', …)` + from `init`/`start`); delete the key. + - `contributes.menus` → app `navigation` / `manifest.navigationContributions` + (ADR-0029 D7); delete the key. + - `contributes.themes` → the stack-level `themes` metadata collection (an + unrelated `ThemeSchema` surface); delete the key. + - `contributes.translations` → the `translation` metadata type: + `defineTranslationBundle` in `defineStack({ translations })`; delete the key. + - `contributes.actions` → the stack `actions` collection or + `engine.registerAction`; delete the key. + - `contributes.drivers` → register a kernel service named `driver.*`; delete + the key. + - `contributes.fieldTypes` → nothing (no registration seam exists; the + vocabulary is the spec `FieldType` enum); delete the key. + - `contributes.functions` → `defineStack({ functions })`; delete the key. + - `contributes.commands` → oclif native plugin auto-discovery (an `oclif` + section in the plugin's own `package.json`; see `cli-extension.zod.ts`); + delete the key. + + **What stays:** `contributes.kinds` (the block's one live member) and + `contributes.routes` (an open enforce-or-remove fork, #10726 — deliberately + untouched here). Runtime behaviour is unchanged: nothing ever read the nine + members, so removing them removes no behaviour; a stored manifest still + carrying one degrades to a single `[metadata_spec_invalid]` log line at + registration rather than a boot failure. + + D3 semantic entry `plugin-manifest-contributes-dead-members-retired`; no D2 + conversion, because a package manifest is not a stack collection member + (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion + would be a transform with no seam that ever runs. +- bc56e18: feat(spec): retire `contributes.routes` — the plugin-manifest block's last dead member (#10726, ADR-0049 enforce-or-remove; maintainer-ruled Option B 2026-08-22) + + + + **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). + + `contributes.routes` was the one member #10724's nine-member retirement + deliberately excluded: removing it needed a ruling, not a tombstone, because + the key was the only *declared* channel for a real capability (serving a + code-handler endpoint) and four published surfaces — a customer-published + skill among them — taught it as working machinery. The measurement (#10627, + controlled, three repos, cloud leg closed clean by #10812) is that nothing + ever read it: the HttpDispatcher never registered a prefix from the + declaration, so an entry parsed cleanly and served nothing. The maintainer + ruled Option B (remove; redirect the author-facing materials to the + imperative mount). The doc corrections landed first (PR #11327); this change + is the removal half, plus the two remaining teaching sites (#11328): the + worked manifest example in `plugin-rest-api.zod.ts` and the `router` + delivered-form comments in `metadata-plugin.zod.ts`. + + **What is refused:** authoring `contributes.routes`. It is a `retiredKey()` + tombstone (neither `ManifestSchema` nor the `contributes` object is + `.strict()`, so a plain deletion would have silently stripped the key), so + authoring it is a `tsc` error and a parse error carrying the prescription. + + **FROM → TO:** + + - `contributes.routes: [{ prefix, service, methods? }]` → mount the route + imperatively: resolve the `http.server` service from the plugin context and + register the handler on `kernel:ready`; delete the key. A declarative + endpoint over a pipeline the platform already runs (query/return records, + trigger a flow) is `defineStack({ apis })`. + + **What stays:** `contributes.kinds`, now the block's sole live member + (engine → `registry.registerKind`). Runtime behaviour is unchanged: nothing + ever read the key, so removing it removes no behaviour; a stored manifest + still carrying one degrades to a single `[metadata_spec_invalid]` log line at + registration rather than a boot failure. + + D3 semantic entry `plugin-manifest-contributes-routes-retired`; no D2 + conversion, because a package manifest is not a stack collection member + (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion + would be a transform with no seam that ever runs. +- be21955: feat(spec): retire `contributes.kinds[].globs` — the declared file-type watch patterns nothing ever read (#11169, ADR-0049 enforce-or-remove; maintainer-ruled 2026-08-24) + + + + **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). + + **Removed key:** `manifest.contributes.kinds[].globs`. + + The schema promised that declaring `globs` "enables the system to parse and + validate new file types" (its own example: a BI plugin handling + `*.report.ts`). The promise was never kept: real glob-driven artifact + discovery reads `filePatterns` off the metadata type registry — which + `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records + outright — so an authored `globs` was accepted, stored, served back through + `GET /metadata/kind`, and never consulted. Measured (PR #11168, re-verified + with positive control at claim): zero value reads anywhere; the only non-test + occurrences of the path were the schema declaration and two type positions. + + **FROM → TO:** `kinds: [{ id, globs: […], description? }]` → + `kinds: [{ id, description? }]` — delete the `globs` key; the kind's `id` and + `description` are unchanged and still register. The key is a `retiredKey()` + tombstone, so authoring it is a `tsc` error and a parse error carrying this + prescription. + + **What stays:** the `contributes.kinds` bucket itself and its `id` field + (live: engine → `registry.registerKind`, served via `GET /metadata/kind`). + File-type discovery remains single-channel on the metadata type registry's + `filePatterns`; if plugin-extensible discovery is ever wanted, it gets + designed against that registry, not revived here. The `registerKind` / + `getAllKinds` type positions drop `globs` (type-only; the parameter widens). + + D3 semantic entry `plugin-manifest-kind-globs-retired`; no D2 conversion (a + manifest is not a stack collection member — no seam would ever run it). +- 15d58db: feat(spec): retire the import mapping `lookup` transform's steering params (#10329, 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). + + `ImportFieldMappingSchema.params` declared four keys whose only stated purpose + was to steer the `lookup` transform — `object` ("Lookup Object"), `fromField` + ("Match on"), `toField` ("Value to take"), `autoCreate` ("Create if missing") — + and the import path read none of them: `applyMappingToRows` handles `lookup` in + the same branch as `none` (the cell is copied through unchanged), and reference + resolution runs afterwards in `import-coerce.ts`, driven by the target field's + own metadata. Implementing them would be a second reference-resolution dialect + on the import path, which the code declines to build and the #10329 triage + ruling declined again. `autoCreate` was the one with teeth: it read as + "create the referenced record when nothing matches", and nothing was ever + created — with or without the key, an unresolved cell fails its row with + `import_reference_not_found`. + + FROM → TO, per key (all four are pure deletions — none ever had an effect to + lose, so removing them changes no import behaviour): + + - `fieldMapping[].params.object` → *(removed)*. The referenced object is the + target field's own declared `reference`. + - `fieldMapping[].params.fromField` → *(removed)*. The pipeline matches the + cell's display value against the referenced object itself. + - `fieldMapping[].params.toField` → *(removed)*. Resolution always writes the + referenced record's id. + - `fieldMapping[].params.autoCreate` → *(removed)*. Create or import the + referenced records first, then import the rows that point at them. + + One-line fix: delete the four keys (and any of their old alias spellings) from + `fieldMapping[].params`; `os migrate meta --from 17` lists the mechanical edits + for existing sources. + + The retirement kit: + + - `params` is a `strictObject`, so the keys are strict-deleted with a + `guidance` prescription per spelling — the four canonical keys and the + eleven ex-alias spellings (`lookupObject`/`targetObject`, + `match`/`matchOn`/`matchField`/`keyField`, `returnField`/`valueField`, + `create`/`createIfMissing`/`upsert`) all land on the full prescription + rather than a "did you mean" pointing at a key that is also gone (the + 17.0.0 #4509 treatment, one level down) + - ADR-0087 registration: the D2 conversion `mapping-lookup-params-removed` + (protocol 18), wired into the step-18 chain — `os migrate meta --from 17` + strips the four keys from stored `mappings[].fieldMapping[].params`. No + retired-key table entries: these keys sit one sub-walk level below the + authorable-surface drill (`data/ImportFieldMapping:params` is the walked + row, and it stays live), so there is no `defKey:name` row to register or + age out + - pin tests (`mapping.test.ts`): refusal pins per key asserting the + prescription (the `autoCreate` pin asserts the row-fails truth), alias + routing pins, and a surviving-surface pin (`value`/`valueMap`/`separator` + untouched) + - liveness ledger: `liveness/mapping.json`'s `fieldMapping` sub-walk boundary + note now records the retirement instead of parking the finding + - docs: the `import-mappings.mdx` warning about the inert params is deleted + along with the keys; the generated mapping reference no longer lists them +- d63b014: feat(spec)!: `ObjectMasterDetailFormPropsSchema.formType` narrows from bare `string` to the measured `simple | tabbed` (#11873 — the spec half of objectui#5939). + + **Newly rejected:** `wizard`, `split`, `drawer` and `modal` — each names an `object-form` renderer branch that breaks `object-master-detail-form`'s atomic parent+details contract (wizard mounts only the current step and turns the Save bar into Next; split persists via `dataSource.create` around the batch; drawer/modal move the parent half into a portal dialog the Save bar cannot submit). Each refuses with a per-value prescription; any other string (e.g. `wizzard`) now gets the plain enum refusal instead of parsing clean and rendering a silently sectionless parent form. + + **Write instead:** `simple` or `tabbed` — the two variants the renderer honours end-to-end for the parent half. For a wizard/split/drawer/modal presentation without inline details, author an `object-form`, whose `formType` keeps all six values. + + Breaking ships as minor per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + +- 9abe4e4: The #11566 `maxLength` narrowing (shipped in 17.x: `z.number().int().min(1)`, refused outside `BOUNDED_STRING_FIELD_TYPES`) is now registered in the ADR-0087 migration ledger (#11950) — the enforcement PR deliberately deferred the entry because the registry file was serialized behind an in-flight change. Following the #8321 `scale`/`precision` template, the major-18 semantic entry carries both halves: the mechanical one (delete the key where it was misplaced — inert by construction outside the write-time validator's bounded-string branch) and the judgment one (a malformed value on a bounded-string type WAS consumed by the validator's raw comparison — `maxLength: 0` accepted only empty strings, a negative value refused every write — so only the author knows the bound they meant; the entry tells them to re-declare it). `objectstack migrate meta`, `spec-changes.json` and the upgrade guide surface the entry at the major boundary; no accept/reject behaviour changes in this release. + + +- 2cc7122: `FieldSchema.maxLength` tightens on both axes (#11566, maintainer ruling 2026-08-24). Shape: the key is now `z.number().int().min(1)`, so `maxLength: 0`, negative and non-integer declarations are refused at parse — none of them is a character length, and `maxLength: 0` measurably sent the SQL schema-drift planner asking for `varchar(0)` DDL at severity error/destructive before #11431 taught that consumer to defend itself. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the write-time validator's ten — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code` — the only one of the three previously-disagreeing lists with a measured reader, exported as `BOUNDED_STRING_FIELD_TYPES`. + + What newly gets rejected: `maxLength: 0` / negative / non-integer on any type, and `maxLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms (`field.form.ts`, previously three types; `object.form.ts`, previously nine) converge on the same ten. Already-legal declarations (a positive-integer `maxLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. + + The ADR-0087 ledger entry for this narrowing is deferred to #11950 (the migration registry file was serialized behind an in-flight change when this landed), following the #8321 `scale`/`precision` template: a mechanical delete of malformed or misplaced values plus a semantic re-declare prescription at the next major boundary. +- 311433f: feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) + + **BREAKING** accept-set narrowing at the metadata write door, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + Metadata item names — the `name` half of the `type`/`name` pair that keys + `sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: + the empty string, `//`, `'Views/All Leads'` and slash-compound spellings + (`views/all_leads`) were all accepted and stored, and a slash in the name + bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz + name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). + Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. + + The grammar is now **declared in spec** (`MetadataItemNameSchema` / + `METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase + snake_case segments, optionally dot-qualified — the family + `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing + `ViewItemNameSchema` dotted declaration (same segment source, one grammar; the + view-item identity keeps requiring its dot). And it is **enforced at the + publish door** (`saveMetaItem` and `publishMetaItem` in + `@objectstack/metadata-protocol`): an off-grammar name is refused + `400 INVALID_REQUEST` with the grammar and the dotted prescription in the + message, and nothing is persisted. The slash bypass of + `refuseUnmintableMetaType` closes as a consequence. + + **What an author writes instead.** A flat snake_case name (`crm_lead`) and a + dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A + name that spelled a sub-resource with a slash (`views/all_leads`) is + re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity + whose prefix recovers the owner) or flattened with an underscore + (`views_all_leads`); containment is expressed by structure, never by a + separator inside the identity string. A translation item conventionally named + after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in + its required `locale` field (`"zh-CN"`), which has been the item's real + identity key all along. + + Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue + row remains listable and clearable. The in-repo stored corpus was measured at + **zero** slash-bearing item names (#12176 census, re-asserted at land time); + out-of-repo stored slash rows, if any exist, are reported by their deployment's + migrate run rather than rewritten silently. + + +- 9abe4e4: `FieldSchema.minLength` tightens on both axes (#11949, maintainer ruling 2026-08-25) — `maxLength`'s twin defect pair (#11566), closed with the same template. Shape: the key is now `z.number().int().min(1)`, so `minLength: 0`, negative and non-integer declarations are refused at parse. The lower bound is 1 by ruling: "no minimum" is expressed by omitting the key, not by declaring a vacuous truth — `minLength: 0` can never fail, and a permanently-true declaration is exactly the noise an AI metadata author mass-produces, so it is refused loudly at authoring time. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the `BOUNDED_STRING_FIELD_TYPES` set — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (twelve members since #11875) — the same set `maxLength` converged on. + + What newly gets rejected: `minLength: 0` / negative / non-integer on any type, and `minLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms converge on the same set (`field.form.ts` previously showed the key for three types; `object.form.ts` for nine). Already-legal declarations (a positive-integer `minLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. + + +- 9086761: `FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent). + + Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling. + + There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save. + + `@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch. +- f11fc61: feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408) + + Accept-set **widening** — no existing document changes meaning and nothing is + removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out + family): objectui's shipped runtime reads `objectDef.editMode` (record-edit + routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG + announces the key to authors, while the spec's strict parse rejected it with + `unrecognized_keys` — so an author following objectui's documentation was + refused by every spec-validating path and the key only worked through data + sources that skip validation. + + The object document now declares it beside the other display hints + (`nameField`, `highlightFields`, `stageField`): an optional cross-renderer + edit-interaction intent — `'modal'` opens the edit form as a dialog over the + current view, `'page'` navigates to a dedicated full-page edit route, absent + lets the renderer pick its own default (objectui defaults to modal). Values + outside the enum are rejected as a located value error at `editMode`. + + Consumer-side follow-up (not in this change): objectui retires its + `ObjectSchemaClientExtensions.editMode` client-extension member and lets the + spec derivation carry the key — its pinned rejection tests flip by design. + That retirement is **release-gated** on the `@objectstack/spec` release + containing this change (per the recorded ruling), not merely on this merge. +- 8f79379: feat(spec): `ComponentPropsMap['object-grid'].data` converges onto `ViewDataSchema` (#12039, objectui#6207 Option A) + + **BREAKING** accept-set change on one props-map entry, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + Two spec authorities disagreed on the KIND of `object-grid`'s `data`: + `ViewDataSchema` — the authority objectui#5090 ruled the registry declaration + against, and what `ObjectGridSchema.data` resolves to — is an object + discriminated on `provider` (`object` / `api` / `value` / `schema`), while + `ComponentPropsMap['object-grid'].data` said `z.array(z.unknown())`. Measured + on `@objectstack/spec@17.2.0`: `{ provider: 'value', items: [] }` — the + pinned-legal form — was refused by the props-map entry while the bare array + parsed. Maintainer ruling (2026-08-25, objectui#6207, Option A): the props-map + entry converges onto `ViewDataSchema`; the bare-array form is the deprecated + `staticData` shortcut the objectui#4648 carve-out already refuses to publish. + + Migration — FROM → TO, one wrapping object: + + ```ts + // before (refused now) + data: [{ id: 1, title: 'Inline row' }] + // after + data: { provider: 'value', items: [{ id: 1, title: 'Inline row' }] } + ``` + + The ruled migration check ran with the change: the sweep of generated + artifacts, templates and first-party corpora (examples/, skills/, + create-objectstack, spec fixtures) found zero bare-array `data` authors, so no + rewrite ships. `staticData` (the legacy bare-array shortcut the renderer still + reads) keeps its shape but is not the prescription. + + `ComponentPropsMap['element:number'].filter` (the sibling key of #12039 / + objectui#6206) is NOT changed here: the ruling's binding measurement-first + precondition measured the pinned adapter/runtime refusing the raw + `ViewFilterRule[]` form on the element's primary (analytics) read path, which + forks that key back to triage. objectui#6206 remains open. + + +- e6ca40e: feat(spec): retire `object-grid`'s legacy `defaultSort` fallback (#11805, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`, per the maintainer's #11805 + ruling — 「不需要major」; the migration prescription is registered under + protocol major 18, where `os migrate meta` users will look). + + `ObjectGridPropsSchema.defaultSort` was the legacy second spelling of `sort`: + a single `{ field, order }` pair the renderer read only when `sort` was absent + — measured at the `.objectui-sha` pin (`190fbd01d`), + `plugin-grid/src/ObjectGrid.tsx:1244-1246` (the `$orderby` fetch fallback) and + `:2847`, where the header-arrow path wraps it `[schema.defaultSort]`, the + exact array shape `sort` carries. One intent, two spellings; objectui's mirror + schema is parity-test-only and parses nothing at runtime, so only this + strictObject can refuse the legacy spelling (objectui#5861 retires the + renderer's reads as the consumer half, on its own schedule). + + FROM → TO: + + - `defaultSort: { field, order }` (no `sort` beside it) → + `sort: [{ field, order }]` — the same pair, wrapped in the array shape every + read path honours. + - `defaultSort` beside an authored `sort` → *(removed)*. The renderer's own + precedence made the fallback unread there, so the deletion is lossless. + + One-line fix: rename the key to `sort` and wrap the value in an array; + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + The retirement kit: + + - `retiredKey()` tombstone in `ObjectGridPropsSchema` — authoring the key is a + tsc error (`never`) and a parse error carrying the wrap-and-rename + prescription (the surface baseline line carries `[RETIRED]`) + - ADR-0087 registration: `ui/ObjectGridProps:defaultSort` in + `RETIRED_KEYS_BY_MAJOR[18]`, and the D2 conversion + `object-grid-default-sort-removed` (protocol 18) wired into the step-18 + chain — wrap-and-rename when `sort` is absent, a pure strip when `sort` is + present + - pin tests (`component.test.ts`): a refusal pin asserting the prescription, a + no-materialize pin, and a surviving-surface pin on `sort` + - zero authored occurrences in either repo's corpora (the card's measurement, + re-run at dispatch), so no in-repo source changes ride along +- 52954c0: `IObjectQLEngine.getSchema` now returns `ServiceObject | undefined` instead of `unknown` (#12481) — the #11833 ruling's fork 3 as executed by #12248, applied one member over by inheritance: `ObjectQL.getObject` is literally `getSchema`'s alias (`return this.getSchema(name)`), the class has always answered `ServiceObject | undefined`, and `ServiceObject` lives in spec (`data/object.zod.ts`), so the contract's "engine-local type" rationale for `unknown` no longer applied here either. FROM `getSchema(objectName: string): unknown` TO `getSchema(objectName: string): ServiceObject | undefined` (authored state, ADR-0122, matching `getObject`). Consumers reading `managedBy` / `fields` / `userActions` off the answer no longer need a cast or a private structural re-declaration; `plugin-security`'s engine-owned write guard drops its now-redundant `as EngineOwnedSchemaLike | undefined` narrowing (behaviour unchanged). Implementations conforming to the class's actual behaviour are unaffected; a fake answering a non-conforming shape now fails compile at the member instead of drifting silently. +- aa5994e: feat(spec): Operation Message Catalog gains two refusal-situation keys — + `record_write_denied` and `approval_recall_not_submitter` (#12493, the + spec-side half of the shape-A ruling on #11993) + + `BUILTIN_OPERATION_MESSAGES` gains two situation keys in all four platform + locales (`en`, `zh-CN`, `ja-JP`, `es-ES`), rendering through + `renderOperationMessage` with the same override/locale/fallback behaviour as + the `record_access_denied` family, addressed for deployment overrides as + `errors.record_write_denied` and `errors.approval_recall_not_submitter`: + + - `record_write_denied` — the user can see this record, but changing or + deleting it is beyond their access (the sharing middleware's by-id write + denial; one key for both write verbs). Deliberately NOT + `record_access_denied` restated: that sentence would be false on a row the + read path already admitted. + - `approval_recall_not_submitter` — the user asked to recall an approval + request someone else submitted; the copy names who CAN act (the submitter, + or an administrator — the #3424 admin override). + + Both sentences take no placeholders, re-derived per site as the family + requires: the only nameable facts at the measured throw sites are object API + names and opaque ids, which must not reach a toast. + + **Operator-visible consequence, stated plainly:** this release ships the + catalog keys only — two refusal situations gain localized copy and a + deployment-override address. The emitters still throw their raw English + strings (`FORBIDDEN: insufficient privileges to …` in the sharing + middleware, `FORBIDDEN: only the submitter may recall this request` in + plugin-approvals) until their consumer halves land separately (#12260 and + #11993). Nothing changes on the wire in this release; once the consumer + halves land, end users see these refusals in their own locale, and any + deployment `translation` defining the two `errors.*` keys takes effect then. +- a65db76: `OrgScopingEntitlement` grows two per-deployment wall-shaping keys, both declared by the mounted `org-scoping` runtime and consumed by plugin-security when arming the Layer 0 organization wall, both fail-closed (absent ⇒ byte-identical behaviour): + + - `platformGlobalObjects?: readonly string[]` — objects THIS deployment declares platform-global; Layer 0 does not wall them here (read filtering, the ADR-0123 D2 no-active-org write refusal, the forge guard, and the Layer 1 wildcard-`organization_id` policy drop all follow, because they read the same per-object security meta). Exact machine names only; a junk shape is refused loudly and exempts nothing. + - `suppressUnboundedOrgAdminGrant?: boolean` — the walled-posture `organization_admin` auto-grant hands out `organization_admin_no_bypass` (no unbounded `viewAllRecords`/`modifyAllRecords`) instead; the superseded-variant reconcile converges standing grants in both directions. + + New spec exports: `PlatformGlobalObjectsSchema`, `PlatformGlobalObjects`, `OrgScopingEntitlementSchema`. +- 15eb2c9: feat(security,meta): org-scoped presentation authoring capability `manage_org_presentation` (#12702) + + A tenant org admin in a walled posture can now be granted org-scoped authoring + of exactly the org-overridable presentation types (ADR-0005 tier A: view / + dashboard / report / translation / email_template today — the registry is the + authority) without holding platform-wide `manage_metadata` (maintainer + direction 2026-08-27, quoted in #12701). + + - **spec**: new curated `PLATFORM_CAPABILITIES` entry `manage_org_presentation` + (`scope: 'org'`), seeded into `sys_capability` at boot like its siblings. + Granted by NO shipped permission set — the SaaS operator grants it per + deployment, so existing postures (`single` included) are byte-unchanged by + its existence. + - **metadata-core**: new `metaWriteCapabilityVerdict` — the capability half of + the `/meta` write decision, beside the existing org-scope half + (`organizationIdForMetaWrite`). It admits `isSystem` and `manage_metadata` + exactly as before, and `manage_org_presentation` ONLY when the target type's + registry entry declares `allowOrgOverride: true` (registry-derived via + `declaresOrgOverride`, never a hand-written list) AND the session has an + active organization — the very organization the doors thread, so an admitted + write can only land org-scoped in the caller's own partition: never tier-B, + never env-wide, never another org's. + - **rest**: the four `/meta` item write doors (`PUT` save, `DELETE` reset, + `POST /publish`, `POST /rollback`) run the shared verdict. + `POST /meta/_migrate-stored` stays `manage_metadata`-only — an install-wide + rewrite is env-wide by definition. + - **runtime**: the dispatcher `/meta` `PUT` door runs the same shared verdict + (its `_migrate-stored` twin likewise stays `manage_metadata`-only). + + Refusals keep their transports' existing envelopes (REST `403 FORBIDDEN`, + dispatcher `403 PERMISSION_DENIED`); the tier-B refusal sentence is + byte-identical to before, and the tier-A sentences name the sanctioned path + without disclosing the caller's own grants (#7450). Platform `manage_metadata` + behaviour is unchanged on every door. +- 2a6122b: feat(spec): retire `page.components[].responsive` and the `ResponsiveConfig` layout vocabulary; repair every shipped text that prescribed it (#11027, ADR-0049 D2) + + + + **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). + + `page.components[].responsive` was the LAST carrier of the per-breakpoint + LAYOUT block (`ResponsiveConfig`: grid columns / visibility / display order on + the Tailwind `xs…2xl` axis) — and the destination the + `dashboard.widgets[].responsive` tombstone (#4876) prescribed verbatim as the + live alternative ("which objectui `useResponsiveConfig` really does read"). + Measured across objectstack + objectui with the tsc-probe methodology + (positive and negative controls; objectui `3b147a367`, objectstack + `8d21f7a76`): that claim was false. objectui's two complete, published + implementations of the contract — `useResponsiveConfig` (`@object-ui/mobile`) + and `ResponsiveProtocol` (`@object-ui/core`) — had ZERO callers, nothing in + either repo read `.responsive` off a page component, objectui's own + `BaseSchema` node interface never declared the key, and zero authored + instances exist. An author following the shipped prescription moved an inert + key to an inert key and was told it now works. Identical disposition to + `view.responsive` (#3896) and `dashboard.widgets[].responsive` (#4876) on + identical evidence. + + **What is refused:** an authored `responsive` on a page component. The key is + a `retiredKey()` tombstone, so authoring it is a `tsc` error and a parse error + carrying the prescription. + + **What leaves with it:** `ResponsiveConfigSchema` / `ResponsiveConfig`, + `BreakpointColumnMapSchema` / `BreakpointColumnMap`, + `BreakpointOrderMapSchema` / `BreakpointOrderMap`, and the `BreakpointName` + enum — no other authorable carrier existed, and an exported value schema with + no consumer reads as a capability (#3950; the `PerformanceConfigSchema` + precedent). Importing any of them is TS2305 from this release. + + **What stays:** `responsiveStyles` (ADR-0065, `ResponsiveStylesSchema` / + `StyleMapSchema`) — the per-breakpoint channel objectui really compiles to + id-scoped CSS — is untouched and is what every repaired text now points at. + + The redirect repair (the reason this ships as one change): four author-facing + surfaces shipped the false redirect and are corrected together — the #4876 + tombstone prescription (`dashboard.zod.ts`), the generated widget reference + page, the protocol upgrade guide prose, and the protocol-17 migration + rationale that `os migrate meta --from 16` prints. The `dashboard.json` + liveness note and the #4876 conversion summary carried the same claim and are + corrected too; `responsive.zod.ts`'s `hidden → hiddenOn` alias curation, which + justified itself by `useResponsiveConfig`'s return shape, leaves with the + schema that hosted it. + + The retirement kit: + + - `retiredKey()` tombstone at the schema (`packages/spec/src/ui/page.zod.ts`), + prescription pointing at `responsiveStyles` with the CSS translations for + `columns` / `hiddenOn` / `order` + - ADR-0087 registration: retired-key entry `ui/PageComponent:responsive`, four + retired-def entries (`ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, + `ui/BreakpointOrderMap`, `ui/BreakpointName`), and the D2 conversion + `page-component-responsive-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips the key from authored pages at + every component position, region, slot, or nested container (pure lossless + delete; it never had an effect to lose) + - pin tests (`page.test.ts` — refusal pin asserts the prescription; a positive + pin parses a component without the key and asserts `responsiveStyles` + survives; `dashboard.test.ts`'s #4876 pins now assert the corrected + prescription instead of the false redirect) + - `ResponsiveStylesSchema` guidance for `columns` / `hiddenOn` / `order` now + names the retirement and the CSS that IS applied, instead of prescribing the + dead sibling key + - generated baselines/docs follow the schema (authorable surface, JSON-schema + manifest, api-surface, export-origins, spec-changes, upgrade guide, + reference docs, skill references) + - objectui's two dead consumer implementations are the other half of this + measurement and are queued under objectui#4773 (this package's texts no + longer point authors at them) + + ## FROM → TO + + ```ts + // before — parsed green; no renderer ever applied any of it + { + type: 'element:text', + id: 'kpi_label', + responsive: { + columns: { xs: 12, lg: 4 }, + order: { xs: 2, lg: 1 }, + hiddenOn: ['xs'], + }, + } + + // after — express per-breakpoint behaviour as scoped CSS (ADR-0065), which + // objectui compiles and applies (desktop-first buckets) + { + type: 'element:text', + id: 'kpi_label', + responsiveStyles: { + large: { gridColumn: 'span 4', order: '1' }, + small: { gridColumn: 'span 12', order: '2' }, + xsmall: { display: 'none' }, + }, + } + ``` +- 225e769: Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap. + + `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, and the open string arm is deliberate: custom and registered components (`object-grid`, `mcp:connect-agent`, `custom.widget`, kebab SDUI blocks) keep parsing exactly as before — nothing about the parse changed. What is new is that the spec now answers for its own namespaces (`page:` `record:` `nav:` `global:` `user:` `ai:` `app:` `element:`, derived from the enum): a type inside them that the vocabulary does not declare is refused at author time by the new gating rule `component-type-unknown` (`os validate` / `os build` / `os lint`), with the closest declared spellings suggested. Previously `global:serch` validated clean and the published page drew a literal "Component Placeholder" scaffold in front of the end user. + + - `@objectstack/spec` exports the vocabulary claim from `@objectstack/spec/ui`: `RESERVED_COMPONENT_TYPE_NAMESPACES` (derived), `KNOWN_COMPONENT_TYPES` / `KNOWN_COMPONENT_TYPE_CANDIDATES`, `STRING_ARM_REGISTERED_TYPES` (the evidenced ledger of registered-but-row-less types, currently `record:line_items`), and the `hasReservedComponentNamespace` / `isKnownComponentType` predicates. + - `@objectstack/lint` ships `validateComponentTypes` (rule id `component-type-unknown`, severity `error`) on all three CLI commands; the runtime publish door is deliberately deferred pending a measured false-refusal budget over stored tenant page rows. + + If a page authored a type in a reserved namespace that nothing declares, the fix is the rule's own hint: rename to the suggested declared type, or move a genuinely custom component to its own namespace (e.g. `my-plugin:widget`) so it cannot be mistaken for platform vocabulary. +- 8af88dd: feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, 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). + Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's + recommendation B; **the keys return with the M2 lifecycle initiative** (feature + + RBAC in one batch) — anchor card #1883 stays open. + + `allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and + `purge` (hard-delete / GDPR erase) ObjectQL operations that have never + existed: no destructive lifecycle verb is in the engine's dispatch vocabulary + (pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). + Authoring the bits granted nothing — and in the `allowPurge: false` direction + the failure was ADR-0049's worst false-compliance shape: an admin believed a + lock on permanent deletion existed when the operation itself did not. The + sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` + door) and is untouched. + + **What is refused:** authoring either key, with any value — both are + `retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the + `permission` metadata root, so the tombstone route keeps the removal audible: + a tsc `never` on the input type plus a parse-time prescription). The former + `restore` / `purge` bare-verb aliases now answer with the same prescription + instead of a rename onto a tombstone. The tombstone rides the `.extend()` + clone into `EffectiveObjectPermissionSchema`, so the response-side def carries + the same `[RETIRED]` rows. + + **What stays accepted:** every other object-permission bit parses + byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, + `viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). + + **Runtime (plugin-security):** the evaluator's pre-mapping rows + (`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in + the same batch — with the bits unwritable, a mapping onto them was a claim + about a surface that rejects authoring. Behaviour is deny-before and + deny-after: a dispatched `restore` / `purge` is refused fail-closed by the + `DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even + `modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers + them only when the M2 batch re-adds the rows). `transfer` keeps its row and + its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy + stored value grants nothing, so flagging it guarded nothing real); the + delete/purge/transfer class message is unchanged. + + The retirement kit: + + - `retiredKey()` tombstones + former-alias `guidance` prescriptions at the + schema (`packages/spec/src/security/permission.zod.ts`) + - ADR-0087 registration: retired-key entries + `security/ObjectPermission:allowRestore` / `:allowPurge` (and the + `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 + conversion `permission-allow-restore-purge-removed` (protocol 18), wired + into the step-18 chain — `os migrate meta --from 17` strips the keys from + every object grant in `permissions[].objects` (pure lossless delete; they + never had an effect to lose) + - liveness ledger: both entries flipped to `dead` with the retiredKey evidence + (entries stay — the tombstone keeps the keys in the walked shape, the + `rls.priority` precedent) + - pin tests (`permission.test.ts` — refusal pins asserting the prescription; + `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant + and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate + no longer reads the retired bit) + - generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, spec-changes, upgrade guide, reference docs) + + ## FROM → TO + + ```ts + // before — parsed green; nothing ever read the bits, no operation existed + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { + allowRead: true, allowEdit: true, + allowRestore: true, // claimed: can undelete — nothing enforced it + allowPurge: false, // claimed: GDPR erase locked — no lock existed + }, + }, + }); + + // after — delete the keys; restore/purge dispatches are denied fail-closed + // until the M2 lifecycle batch ships the operations WITH their RBAC bits + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { allowRead: true, allowEdit: true }, + }, + }); + ``` + + +- b72db01: fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescriptions are registered + under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 + semantic entry `plugin-auto-restart-never-reinitialised` — where + `os migrate meta` users will look). Graded `minor` rather than `major` for the + same reason #12340 and #12428 were, the day before, in this same module. + + ## What was measured + + `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped + there. The comment above the call read *"Call destroy and init to restart"*, + and `init` appeared in `health-monitor.ts` **only inside that comment**. So a + plugin whose health checks crossed `failureThreshold` with `autoRestart: true` + got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`, + and periodic health checks that carried on running against the destroyed + instance. The default check when no `checkMethod` resolves is + `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes + indefinitely — so the **terminal** report on a torn-down, never-re-initialised + plugin was `healthy`. + + Reproduced at `ee3595cefd` before anything was changed, with + `successThreshold: 3`: + + ``` + round 1 (failing): status=failed destroyed=0 alive=true + after backoff: status=recovering destroyed=1 alive=false + recovery round 1: status=recovering destroyed=1 alive=false + recovery round 2: status=recovering destroyed=1 alive=false + recovery round 3: status=healthy destroyed=1 alive=false + ``` + + #11955 made that report *more* convincing rather than less: reaching `healthy` + now costs `successThreshold` consecutive passing rounds, so a destroyed plugin + has to earn a declared number of passes before it is misreported. + `restartAttempts` was incremented as though a restart had occurred, and + `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin + that was never brought back up. + + ## Why REMOVE and not the other two ADR-0049 states + + **ENFORCE** would have to build the restart, and the class cannot host one. + `Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call + sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`, + `kernel.ts:607`), both over the full plugin list, with a context that is + `private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain + one, so a host-provided re-init hook would have had nothing to call. (Positive + control for that scan: the same pass resolves five real non-test + `plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a + per-plugin re-init API for a caller that does not exist — no runtime constructs + `PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision + names as the wrong default at this milestone, where the shippable liability is + the false promise and not the missing feature. + + **EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR + corpus returned **zero** mentions of plugin auto-restart, against 118 control + hits for "health" and 13 for "hot reload" in the same corpus. + + `maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than + as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and + *"Backoff strategy for restart delays"* have nothing left to be the vocabulary + **of** — the test that took `distributedConfig` out with the `stateStrategy` + value it was documented as requiring (#12340). + + ## What changes for a host + + All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not + `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a + milder form of the defect being retired. A TypeScript host gets a `tsc` error + (the keys are typed `never`); a parse raises the prescription; and + `PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of + them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown + before any state is stored so a refused config leaves no half-registered plugin + behind. + + `PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that + crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and + left running; acting on that is the host's job in this host-driven library + (#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` + and restart at the level that owns the plugin's lifetime. + + Everything else in the monitor is unchanged: registration, periodic checks, the + `timeout` race and its refd-timer guard (#4875), both failure routes sharing the + counters (#11852), and `successThreshold` binding from every status that records + a failure (#11955). `recovering` is now written only by the success branch — + the one writer that ever meant it. +- 177ebdc: Add `PLATFORM_PLUGIN_WIRED_RUNTIMES` (and its row type `PlatformPluginWiredRuntime`) to the kernel platform-capability module: a companion provenance roster, keyed by npm package name, for the out-of-repo runtimes that reach the kernel through app `plugins[]` wiring rather than through a `requires` capability token — today `@objectstack/organizations` (loaded by `serve` off the resolved tenancy posture) and `@objectstack/security-enterprise` (which also backs the `hierarchy-security` token). The token-keyed `PLATFORM_CAPABILITY_PROVIDERS` map structurally cannot describe a package that backs no token; this roster makes "is this out-of-repo package real, and where does it ship from?" machine-readable for that population. Provenance only — it adds no capability token, changes no `requires` resolution, and encodes no posture-to-token semantics; drift tests pin the two rosters to agree wherever they name the same package. +- 8d237b4: Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. + + The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. +- 2d8dd8d: Publish the built-in `sys_activity.type` vocabulary from `@objectstack/spec` (#11807). + + `SYS_ACTIVITY_BUILTIN_TYPES` (and the derived `SysActivityBuiltinType` union) is now exported from `@objectstack/spec/data` (`feed.zod.ts`, alongside `FeedItemType`), and the `sys_activity` object declaration in `@objectstack/plugin-audit` derives its `type` options from it — one source instead of a hand-copied list per consumer. + + The constant is the platform's **built-in set, not the column's value domain**: `sys_activity.type` stays an open, author-extensible vocabulary (#11507 ruling — an app may contribute values via `activityMilestones[].type`, ADR-0052 §5b.2, or its own inserts, and undeclared values are stored verbatim). It is deliberately a plain `as const` tuple rather than a `z.enum`, so it cannot be used as a validator; consumers must render unknown values, never drop them. UI packages that hand-copied the list (objectui's feed-kind census, which drifted the day #11522 added `scheduled`) can now read this export instead. +- 348860c: feat(spec): refuse an authored `radio` + `multiple: true` at the schema layer (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C) + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + An author could declare `{ type: 'radio', multiple: true }` and the producer + honoured it everywhere the widget could not: the data layer stored an array, + validated it as multi, split it on import and inferred multi arity for action + params, while the one renderer `radio` has draws a single-value radio group + with zero diagnostics. Declared multi, rendered single — the contradiction sat + inside `packages/spec` itself, where `SINGLE_OPTION_TYPES` calls `radio` + single-choice on one line and `MULTI_CAPABLE_TYPES` carries it on another + because it "shares the select branch". + + Per the maintainer ruling recorded 2026-08-22 on objectui#4015 (Option C, + 「接受所有」), `FieldSchema` now refuses the authored combination at parse + time — the seam every publish crosses, both for a standalone `field` document + and for fields embedded in an `ObjectSchema` — with a diagnostic that names + the field, names the illegal pair, and prescribes the correctly-named + multi-choice types: `checkboxes` (all options visible, radio-like layout), + `multiselect` (dropdown) and `tags` (free-form values). + + **What stays accepted, byte-identically:** `radio` without `multiple` + (including its materialized `multiple: false`), `radio` with an authored + `multiple: false`, `select`/`lookup`/`user`/`file`/`image` with + `multiple: true`, and the inherently-multi types with or without the redundant + flag. Because `multiple` materializes `.default(false)`, the refusal can only + ever fire on an authored `true` — a defaulted value never trips it, and + `parse(parse(x))` stays stable (pinned). + + **What is deliberately untouched, per the same ruling:** `MULTI_CAPABLE_TYPES` + and `isMultiValueField` in `field-value.zod.ts` keep `radio`, so data at rest + that was written under the old contract keeps its read path and no + stored-shape migration is paid — that is the whole reason Option C was + preferred over narrowing the sets themselves. A test pins `radio`'s membership + so a future cleanup trips loudly. `packages/objectql`'s record-validator + select/radio branch likewise stays, as a data-safety fallback for stock. + + Measured before landing (both repos, examples/docs/fixtures/tests included): + 21 `type: 'radio'` declarations, none carrying `multiple: true` — the refused + combination has zero occurrences, so no existing metadata is invalidated. The + ruling attaches an explicit flip condition: if deployed tenant metadata + carrying the combination with stored data is ever found, entrance rejection + alone would strand it and the set-narrowing option becomes required. + + +- 5383fa6: React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying ``'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `` / `` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window. +- 1a6a19c: feat(spec): open `RecordActivityProps.types` to author-contributed activity kinds (#11658) + + Accept-set **widening** on a published authorable prop, executing the + 2026-08-24 maintainer ruling on #11507: `sys_activity.type` is an OPEN, + author-extensible vocabulary — the declared options are the platform built-in + set, ADR-0052 §5b.2 stays a sanctioned author write path, and (verbatim) + "every closed map over this vocabulary is now the bug". + + `RecordActivityProps.types` (the `record:activity` filter, also embedded as + `RecordChatterProps.feed.types`) was that closed map on the authoring surface: + `z.array(FeedItemType)`, a closed enum of the 13 built-in UI kinds, so an + author who contributes an activity type (e.g. `scheduled`, which hotcrm writes + today through the sanctioned ADR-0052 §5b.2 channel) could not name it in the + filter. The element is now `z.union([FeedItemType, z.string().min(1)])`: the + enum branch keeps the built-in kinds visible as guidance (editor autocomplete, + and an `anyOf` member of the generated JSON Schema) while the open-string + branch accepts any author-contributed kind — the union accepts exactly what a + bare non-empty string accepts, so nothing is validated against the built-in + set. A typo'd built-in consequently no longer gets a named rejection; the + ruling accepted that cost rather than re-close the vocabulary. + + Every previously-legal value still parses byte-identically; non-string and + empty entries are still rejected. The `feed.zod.ts` module docblock (and the + generated `feed.mdx` reference page) no longer claims these enums have "no + backend dependency": `FeedItemType` is the target of the map UI consumers + apply to the open `sys_activity.type` column — a backend coupling, not a + backend import. + + +- 527e050: Declare `headerColor` on the strict `record:details` section schema as a closed enum (#12126, maintainer ruling A 2026-08-26). The key was deliberately refused by #11661 because the renderer's only read was a template-literal Tailwind class that generated no CSS; objectui#6294 (merged 2026-08-25) replaced that read with a lookup of complete class literals, so the refusal outlived its recorded reason. The vocabulary is exactly the six tokens that lookup ships — `muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10` — tints only (`CardHeader` sets no foreground, so solids would need a paired `text-*-foreground`). Declared = enforced: `objectstack validate` refuses anything outside the enum at authoring time — including the renderer's `bg-*` pass-through spellings, which render only when the host app's Tailwind build happens to generate the class — instead of shipping a header that silently does not paint. Optional with NO schema default; the `title` withhold from #11661 stands unchanged. +- dd33bf9: Declare three more `record:details` section keys the renderer has honoured all along (#11661, inheriting the #11289 ruling): `defaultCollapsed` (start a `collapsible: true` section collapsed; renderer default expanded), `icon` (heading icon, lucide name; non-identifier values render as literal text) and `description` (plain-string sub-heading under the section heading). All three were refused by the strict section schema, so `objectstack validate` warned an authored key "did nothing" while the renderer read it. All three are optional with NO schema defaults — the fallbacks stay the renderer's. The same measurement's `title` and `headerColor` deliberately remain refused: `title` is a second spelling of the heading slot `label` declares (held for a declare-vs-converge ruling), and `headerColor` only reaches the DOM as a template-literal Tailwind class that generates no CSS (dead-in-practice; reported as an objectui finding). +- 4cb2a90: Declare `hideEmpty` / `collapsible` / `showBorder` on `record:details` + sections — the three keys objectui's renderer has honoured all along + + `RecordDetailsRenderer` spreads every authored section through to + `DetailSection`, which reads all three — but the strict section schema + declared only `name` / `label` / `columns` / `fields`, so + `objectstack validate` warned that an authored key "did nothing". For + `hideEmpty` the warning hid the one key that decides whether a section + exists at all: the renderer forces `hideEmpty ?? true`, and a section whose + fields are all empty then renders nothing — no heading, no skeleton — with + no declarable spelling to ask the skeleton back (a freshly created record + losing two of its three authored sections is how this surfaced). + + Accept-set widening only; the renderer is unchanged (maintainer ruling + 2026-08-23, direction 1). All three keys are optional with **no schema + default** — the fallbacks are the renderer's, and the describe() texts + state them as measured at the `.objectui-sha` pin: `hideEmpty` on; + `collapsible` off; `showBorder` derived (on for a titled section, off for + an untitled one). `hideEmpty: false` now keeps a section's label skeleton + on an all-empty record, and schema and runtime finally say the same thing. +- 4d25d22: **BREAKING (platform object removed):** the `sys_scim_provider` platform object is retired (#11757, ruled on #11693 — leg 1a of the #11632 SCIM epic). + + FROM → TO, per surface: + + - `SysScimProvider` (export of `@objectstack/platform-objects` / `.../identity`) → removed, no replacement export. Fix: delete the import. Stable SCIM state lives on the seven `sys_scim_*` stable-model objects (#3653), and connection credentials on `sys_scim_connection_credential`. + - `sys_scim_provider` in `PLATFORM_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) → removed. `isPlatformProvidedObjectName('sys_scim_provider')` is now `false`, so a stack referencing the name is flagged as a probable typo instead of resolving. + - plugin-auth: the object is no longer provisioned, and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry — the installed stable `@better-auth/scim@1.7.1` derives no such model, so the entry bridged nothing. + - plugin-security: the `BETTER_AUTH_MANAGED_OBJECTS` write-deny entry for it is gone with the object (the list is pinned bidirectionally against `managedBy: 'better-auth'` declarations). + + The rc.1-era row was written only by the retired `/scim/generate-token` endpoint; after the stable-1.7.1 migration (PR #12726) nothing could write to it. Per the maintainer's ruling (2026-08-24, 「不需要考虑历史数据」; reaffirmed 2026-08-25 — SCIM has no real customers), **no data migration ships**: existing `sys_scim_provider` tables in deployed databases are left untouched — no backfill, no reaper, no migrate command. SCIM-enabled deployments re-register connections on the stable surface; the IdP token reissue is a migration-day operator action regardless of this change. + + The ADR-0066 D3 capability-gate pin moves from the retired object to its surviving sibling `sys_sso_provider`, so the gate posture stays test-pinned. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`) and the #12726 precedent on the same ruling. + + +- 1ffee51: Retired-defaulted-key tolerance (#12840, class rule): a RETIRED key that carried a schema default is now refused only when it carries a NON-default value. The retired default parses as inert residue and is STRIPPED — the normalized output does not carry the key, and a parse → serialize round-trip converges to the clean shape. + + Founding case: `permissions[].objects..allowRestore` / `.allowPurge` (#12497). Every artifact built by the published `@objectstack/spec` 17.x has both keys materialized as `false` in every permission entry (the pre-retirement schemas emitted `z.boolean().default(false)`), so the #12497 refusal was sentencing every previously built artifact — marketplace packages, installed environments — to a boot failure on the next runtime upgrade. Those artifacts now parse; `allowRestore: true` / `allowPurge: true` keep the full #12497 refusal with the prescription byte-for-byte, and nothing is un-retired: the keys stay tsc-`never` tombstones on the authoring surface, and the authorable-surface/JSON-schema artifacts still publish the `[RETIRED]` rows. + + Ships as a reusable helper — `acceptRetiredDefaultResidue(schema, residue)` in `packages/spec/src/shared/retired-key.ts` — where `residue` is the retired default captured as a literal at retirement time (never re-read from anywhere live). The next retirement of a defaulted key reuses the helper instead of re-inventing the judgement. +- 5ae4303: feat(spec): make `BaseValidationRuleShape` and `FilterCondition` nameable from the root entry (#11709) + + Additive follow-up to the #11350 root-entry nameability fix, same invariant + (a type that appears structurally in an entry's public declarations must be + nameable from that same entry): a minimal consumer program with an + un-annotated `export default defineStack(...)` and **no** `@objectstack/spec/data` + import anywhere in its program still failed declaration emit with exactly two + TS2883 diagnostics — `BaseValidationRuleShape` and `FilterCondition`, both + mentioned structurally by `defineStack`'s return type but reachable only + through hash-named internal dist chunks. The root entry now re-exports both + types from their declaring `/data` modules (`data/validation.zod`, + `data/filter.zod`), exactly as #11350 did for `FormFieldInput` / + `NavigationItemInput` / `StateNodeConfig`. No runtime change; existing + consumers that already imported these names from `@objectstack/spec/data` are + unaffected. +- ece4dad: Re-export `FormFieldInput`, `NavigationItemInput` and `StateNodeConfig` from the package root entry (#11350). These types appear structurally in the root entry's own public declarations — `defineStack` returns `ObjectStackDefinition`, declared `z.input`, which the declaration emitter expands structurally rather than preserving as an alias — but they were previously nameable only via the `/ui` and `/automation` subpaths. Any consumer letting TypeScript infer a type through a root-entry function (an un-annotated `export default defineStack(...)`) therefore hit TS2883 naming a hash-named internal dist chunk. With the re-exports, that consumer shape declaration-emits cleanly, with no annotation required. Invariant recorded: a type that appears structurally in an entry's public declarations must be nameable from that same entry. +- 735f5c7: **Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). + + `ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. + + Now: + + - **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. + - **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. + - **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. +- 366f895: feat(auth): migrate `@better-auth/scim` from `1.7.0-rc.1` to stable `1.7.1` — the whole-model SCIM migration (#3653, epic #11632) + + The stable line is the rc.2-lineage rewrite: the rc.1 `scimProvider` model, + `/scim/generate-token` endpoint and `storeSCIMToken` option no longer exist, + replaced by seven new models and a three-way connection contract. This lands + the migration atomically: + + - **Seven new platform objects** back the stable models — + `sys_scim_connection_binding`, `sys_scim_group`, `sys_scim_group_member`, + `sys_scim_identity_tombstone`, `sys_scim_projection_grant`, + `sys_scim_subject`, `sys_scim_user` — bridged via `AUTH_MODEL_TO_PROTOCOL`, + registered in the platform-object-names registry, listed in + `BETTER_AUTH_MANAGED_OBJECTS`, and column-pinned by the parity gate (whose + `KNOWN_UNMAPPED_MODELS` shrinks to the empty set: the rc.1-era group + provisioning gap — IdP `/Groups` pushes hitting tables that did not exist — + is closed). + - **SCIM connections stay runtime data.** The stable constructor is satisfied + with an application-owned `authentication.verifyBearerToken` that resolves + the connection from a row at request time — not static boot config, and not + the upstream `managedConnections` catalog (deliberately not adopted). + - **ObjectStack owns SCIM credentials outright** (stable upstream stores no + credential at all): `sys_scim_connection_credential` plus + `scim-connection-service.ts` mint/digest/verify. At rest only an + HMAC-SHA-256 keyed by the deployment auth secret (base64url, + domain-separated) is stored — at parity or better than the rc.1 unsalted + SHA-256 — pinned by `credential-at-rest-posture.test.ts` including live + 401 paths for forged, revoked and expired bearers. + - **The ObjectQL better-auth adapter gains native transactions** + (`engine.transaction`, fail-closed on drivers without `beginTransaction`), + which stable scim requires by assertion for atomic provisioning writes. + - **Scaffold suppression retired**: the `@better-auth/scim>better-call` + `allowedVersions` entry (CLI renderer + blank template) is gone — stable + 1.7.1 peers `better-call@1.4.0` exactly — and its presence ratchets flipped + to absence pins. The `better-auth>better-sqlite3` and four + `@better-auth/utils` entries stay; their retirement conditions are separate + and unmet. + - The pin resolves **1.7.1 exactly** (not `^1.7.1`): 1.7.2 peers + `better-auth`/`@better-auth/core` at `^1.7.2`, which only the workspace + overrides' silencing would "satisfy" while the family is 1.7.1. Floating is + its own follow-up. + + **Semver: minor, argued.** The rc.1 SCIM surface this replaces (generate-token + endpoint, rc.1 bearer tokens, `sys_scim_provider` rows) changes incompatibly — + but that surface is default-off (`OS_SCIM_ENABLED`), was shipped with a + documented "do not let the IdP push groups" boundary, and the maintainer ruled + (2026-08-25) that SCIM has no real customers and old data need not carry: the + one binding constraint is that an existing system upgrades smoothly, which it + does — every table the installed library can write exists at this version, and + SCIM-disabled deployments see no behavior change. A major would move the whole + fixed version group for a feature surface with zero consumers. Deployments + that had SCIM enabled must mint new connection credentials (digests are not + portable from rc.1 on any path — IdP token reissue is a migration-day + operator action regardless of semver level). `sys_scim_provider` itself is + NOT removed here; its retirement is tracked separately (#11757). +- dc75ba8: feat(spec,client): bind published response contracts for the 17 unbound client-SDK methods; retire the false `PackageRollbackResponseSchema` (#12038, ruling 1C · 2C · 3A · 4A · 5A) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` `api/PackageRollbackResponse` + plus the D3 semantic entry `package-rollback-response-retired` — where + `os migrate meta` users will look). + + FROM → TO: + + - `PackageRollbackResponseSchema` / `PackageRollbackResponse` / + `PackageRollbackResponseParsed` → `RollbackToPackageCommitResponseSchema` / + `RollbackToPackageCommitResponse` (`@objectstack/spec/api`). The retired + schema declared a VERSION rollback (`{ success, restoredVersion?, + message? }`) while the live `POST /packages/:id/rollback` route posts + `{ commitId }` and answers the ADR-0067 COMMIT rollback — + `{ success, revertedCommits: string[], failed: [{ commitId, error }] }`. + Read `revertedCommits` / `failed`; there is no `restoredVersion`. + - `PackageApiContracts.rollbackPackage` → *(removed)* — it bound the + wrong-operation schema to the exact live path. No route registration or + SDK generation ever consumed it (zero consumers measured across + objectstack, objectui and cloud; only its own unit test and the #11925 + compile-time guard, both updated in this PR). + + One-line fix: replace any import of `PackageRollbackResponse(Schema)` with + `RollbackToPackageCommitResponse(Schema)` and read `revertedCommits` / + `failed` instead of `restoredVersion`. `PackageRollbackRequestSchema` stays + published (ruled out of the retirement), bound to no route. + + The rest of the change is additive — the recorded five-part maintainer + ruling (2026-08-27) for the 17 client-SDK methods that had no published + response contract: + + - **12 describe-only transcriptions** into `@objectstack/spec/api`, each + from the return type its producer already declares inline (no wire byte + changes): `ListDraftsResponseSchema`, `GetMetaDiagnosticsResponseSchema`, + `FindReferencesToMetaResponseSchema`, `RollbackMetaItemResponseSchema`, + `DiffMetaItemResponseSchema`, `ResolvedBookSchema` (authored beside its + interfaces in `system/book.zod.ts`), `DiscardPackageDraftsResponseSchema`, + `ListPackageCommitsResponseSchema` (the `{ commits }` wrapper declared as + the handler's own), `RevertPackageCommitResponseSchema`, + `RollbackToPackageCommitResponseSchema`, + `ReassignOrphanedMetadataResponseSchema`, `DuplicatePackageResponseSchema`. + - **Ruling 1C**: `GetPublishedMetaItemResponseSchema` is deliberately opaque + (`z.unknown()`) — the route answers an arbitrary metadata item body, never + a union frozen against the type registry. + - **Ruling 2C**: `meta.migrateStored` stays UNBOUND, documented at its two + ledger rows and in the SDK — `StoredMigrationReport` lives in + `@objectstack/metadata-protocol`, and a second declaration would drift. + - **Ruling 4A**: `PackageExportManifestSchema` pins the four fixed keys + (`id`, `name`, `version`, `label?`) and stays honestly open for the + registry-derived plural keys. + - **Ruling 5A**: `PackagePublishResultSchema` and the `ResolvedBook` family + are re-exported into `@objectstack/spec/api` (the namespace the + route-ledger resolver searches) — never a second copy. + - The 18 boundable route-ledger rows in `@objectstack/runtime` and + `@objectstack/rest` now name their `responseSchema`, each stating which + surface's envelope it describes; every named schema carries conformance + coverage (the #3877 rule). + - The client SDK binds 16 of the 17 methods to the published payload types, + replaces four invented test mocks with producer-true shapes, and pins the + `unwrapResponse` mis-unwrap hazard so no bound payload can declare both a + boolean `success` and a `data` key. +- 8619f95: feat(spec): retire `SendTemplateInput.org` — the declared org-overlay resolution tenant id nothing ever read (#11832, ADR-0049 enforce-or-remove) + + + + **BREAKING** accept-set narrowing (compile-time), 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). + + **Removed member:** `SendTemplateInput.org` (`packages/spec/src/contracts/email-service.ts`). + + The member was declared as "Tenant id for org-overlay resolution (when + supported)" and no implementation ever read it: `@objectstack/plugin-email` — + the only `IEmailService` implementation — resolves templates on + `(name, locale)` only, so a caller passing `org` got no org-overlay resolution + and no error; the "(when supported)" hedge was the declaration admitting the + gap. After #11741 landed `organizationId` beside it, `SendTemplateInput` + carried two org-shaped keys of which one did nothing — exactly the shape that + invites an AI author to pick the wrong one. + + **FROM → TO:** `sendTemplate({ template, to, org: tenantId, … })` → + `sendTemplate({ template, to, … })` — delete the `org` key; it never changed + behaviour, so removing it changes none either. It is **NOT** replaced by + `organizationId`: that member is the delivery row's tenant stamp + (`sys_email.organization_id` pass-through, #11741) and opts into no template + overlay resolution. + + **What is refused:** authoring `org` in TypeScript is now an excess-property + `tsc` error (`SendTemplateInput` is a programmatic contracts interface with no + Zod surface, so the compiler is the enforcement channel — pinned in + `email-service.test.ts`). Runtime behaviour is unchanged: nothing ever read + the member, so a JavaScript caller still passing `org` keeps its exact + pre-removal outcome (the key is carried inert and ignored). + + **What stays:** `SendTemplateInput.organizationId` and + `SendEmailInput.organizationId` (#11741, Decision 2 of #11303) are untouched, + semantics included. `RenderTemplateInput` never carried `org`. D3 semantic + entry `send-template-input-org-retired`; no D2 conversion, because the key + only ever appeared in a call-time input bag — no metadata seam ever runs on it + (the `data.engine.update options.upsert` precedent). +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. +- a11c1a5: `signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + + - **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. + - **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). + - **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + + Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. +- a933ed7: **`MetadataProtocol` declares the optional `auditMetaItem` member, and the audit door's request/response schemas join the spec** (#11678 — the #11006 maintainer-ruled pattern, 2026-08-22 option B, carried one door over). + + `GET /api/v1/meta/:type/:name/audit` — the ADR-0010 §3.6 compliance trail behind Studio's 审计日志 / Audit log tab — was a step behind the half-declared publish door #11006 adjudicated: **neither** side was declared (`auditMetaItem` appeared nowhere in `packages/spec`), so the REST door reached the verb through `(p as any)` twice (feature-detection guard + call) and its request literal was compiled against nothing. + + Additive, not breaking: + + - `AuditMetaItemRequestSchema` / `AuditMetaItemRequest` — `{ type, name, organizationId?: string | null, limit? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `organizationId` is nullable because the REST door always sends it, possibly `null` (#8747's fail-closed tenant scoping: `null`/absent = env-wide rows only, never every tenant's). `limit` declares no bounds because the implementation clamps to [1, 500] rather than refusing. `environmentId` stays out by the #9741 ruling (transport-level routing key) — and on this door it is not even on the wire any more (#8747 removed it; the implementation never read it). + - `AuditMetaItemResponseSchema` / `AuditMetaItemResponse` — the `{ events: [...] }` body, newest first, with the closed `operation` (save/publish/rollback/delete/reset) and `outcome` (allowed/denied/forced) vocabularies and the ADR-0010 §3.3 `lockState`. The #9426 miss-vs-fault honesty is recorded in the declared types: `{ events: [] }` is the honest answer for a clean trail, a find-less host engine, or an unprovisioned audit table — never for a missing capability (501 before the call) and never for a failed read (propagated, not invented into an empty trail). + - `MetadataProtocol.auditMetaItem?(request: AuditMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. +- a933ed7: **`DeleteMetaItemRequestSchema` declares the contract members the REST reset door sends** (#11679 — the #11006 maintainer-ruled pattern on the request-shape half). + + `MetadataProtocol.deleteMetaItem` was declared all along, but its request schema declared 2 of the 8 members `DELETE /api/v1/meta/:type/:name` sends — so the door's call site had to stay behind an `(p as any)` cast (removing it surfaced `TS2353` on six keys, the opposite half of the publish door's `TS2339`), and the one member most worth having a contract — `organizationId`, which selects WHICH overlay row a reset destroys (ADR-0005 org partition; an org-less delete reaches the environment-wide row) — was on the wire with no declaration behind it. + + Additive, not breaking — the five contract-level members join the schema, mirroring the implementation's parameter type in `@objectstack/metadata-protocol`: + + - `organizationId?` — tenant scope for the reset (#8805); load-bearing, decides which row the delete destroys. + - `parentVersion?` — the ADR-0008 optimistic-concurrency pin (REST: the `If-Match` header); absent = last-write-wins. + - `actor?` — identity recorded on the history tombstone row (one producer, #7749); absent = recorded actor-less, never "system" (#4556). + - `state?` — `'active' | 'draft'`; `draft` discards the pending draft overlay only. + - `dropStorage?` — destructive opt-in (default false): also drop the object's physical table (`object` + `active` only; never `sys_`). + + Two wire members stay out, by ruling rather than omission: `environmentId` (transport-level routing key per #9741, layered on by `packages/rest`'s `TransportScopedMetaRequest`) — and there are no internal coordination keys on this door (`_skipSeedApply` is publish-batch-only). +- cccbe51: **`MetadataProtocol` declares the optional `publishMetaItem` member, and `PublishMetaItemRequest` joins the spec** (#11006, maintainer ruling 2026-08-22, option B). + + `POST /api/v1/meta/:type/:name/publish` — the promotion half of Studio's designer save-then-publish loop — was a half-declared door: #7294 declared the response (`PublishMetaItemResponseSchema`) while the request shape and the interface member stayed undeclared, so the one HTTP call site reached the verb through a cast and its request literal was checked by nothing (measured: deleting the cast answers `TS2339` — the cast carried member-existence weight, not request-shape weight). + + Additive, not breaking: + + - `PublishMetaItemRequestSchema` / `PublishMetaItemRequest` — `{ type, name, organizationId?, actor?, message?, packageId? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `packageId` is `string | null` with the absent-vs-`null` distinction documented (absent = match any package; `null` = pin to the package-unbound row). `environmentId` stays out by the #9741 ruling (transport-level routing key; `packages/rest`'s `TransportScopedMetaRequest` layers it on), and the internal `_skipSeedApply` batch-coordination key is deliberately not part of the wire contract. + - `MetadataProtocol.publishMetaItem?(request: PublishMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. + + The follow-up that removes the `(p as any)` cast at the REST call site rides the engine lane; this change only declares the contract. +- dfebfc8: feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) + + **BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` + under the repo's launch-window convention for breaking changes — and a widening + in the same edit, so read both directions. + + Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative + 归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol + without being the PostgreSQL this driver emits DDL for, and the driver had no + opinion about any of them — it simply let knex compile whatever it compiles. + Measured on `origin/main` before the change, one `CREATE TABLE` per client: + + ``` + pg / pgnative / cockroachdb "body" text primary key inline + redshift "body" varchar(max) primary key in a separate ALTER TABLE + ``` + + So on Redshift the pre-ruling behaviour was not a failure — it was a table of a + different shape, built quietly, with the deployment finding out when it wrote + data into it. + + **Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches + schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, + `reconcileManagedSchema` — now gets an immediate + `UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` + (newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), + HTTP status `501`, and a message naming the client, every client the driver + DOES emit for, and the supported way to keep the database — manage its schema + out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws + before any statement is issued, so nothing is half-built. Connection, the + connect bound and the #11389 calendar-day parser are untouched: the boundary is + DDL only, drawn where behaviour was actually verified. + + **Recognised (widening).** `pgnative` is now a member of the Postgres emission + family — knex resolves it to the same `postgresql` dialect and the same query + compiler as `pg`, differing only in which npm binding carries the bytes. It was + previously in neither the emission set nor the wire table, so a `date` column + got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact + #11550 defect) and no calendar-day parser. It now behaves identically to `pg` + and carries the #11389 pin. + + **One source of truth.** The pair `cockroachdb, redshift` used to be + hand-written into the connect-timeout table and again into the wire table. It + is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend + the emission sets through it — as does the refusal, which reads the same set. + Adding a future pg-wire client is one edit, and the three answers cannot drift + apart. `mariadb` is explicitly out of the ruling's scope and keeps its third + state: neither recognised nor refused. + + +- d028b37: fix(spec): `StrategyContext.executeAggregate` `aggregations[].method` narrows from `string` to `AggregationFunction` (#12776) + + + + **BREAKING** accept-set narrowing on a published contract, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). + + Two spec-declared surfaces described the same slot and disagreed about its + type: `IDataEngine.aggregate`'s `aggregations[].function` is the closed + six-value `AggregationFunction` enum, while the analytics strategy contract's + `StrategyContext.executeAggregate` declared the same value as + `aggregations[].method: string`. The analytics bridge renames one to the + other, so nothing on the analytics side of that seam was compile-checked + against the engine's vocabulary — a strategy author (very often an AI) got + no compile-time help and hit the bridge's runtime refusal instead. + + FROM → TO: + + - `aggregations[].method: string` → + `aggregations[].method: AggregationFunction` + (`'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`, the spec's + own enum from `@objectstack/spec/data`). One slot, one declaration. + + Who breaks at compile time on upgrade: + + - external CALLERS of `StrategyContext.executeAggregate` that fill `method` + with a value typed `string` (or a literal outside the six) — the values the + bridge already refused at runtime (#11833) now fail `tsc`. + - external IMPLEMENTORS of `StrategyContext` stay source-compatible: a + handler that accepts `method: string` accepts a superset and remains + assignable to the narrowed member. + + The bridge's runtime parse-and-refuse (#11833) stays as defence in depth. + In-repo, `ObjectQLStrategy`'s aggregation locals now carry the enum + end-to-end (`@objectstack/service-analytics`, runtime behaviour unchanged — + the census measured every reachable producer already emitting enum-legal + values only). +- 122ef38: `ToolExecutionContext.surfaceContext` (cloud#1610): an optional, advisory description of what the user is currently discussing — Studio pillar, the selected artifact WITH its type discriminator (page/object/dashboard/report), an optional finer selection, and the canvas mode. Strictly additive beside `currentObjectName`/`currentViewName`; consumers must treat every field as optional and never use it for access decisions. +- 428f9b2: feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) + + Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone + family gets **one** data-plane platform object, declared beside its siblings so + it needs **zero `packages/spec` schema or contract surface** — it is an ordinary + platform object, not a metadata type. (The one spec file touched is the + mechanical name census described below, not protocol surface.) + + The whole schema, per §4: `metadata_type` · `name` · `package_id` · + `organization_id` (nullable, **reserved** — NULL on this entire line; the + per-org dimension is an additive column later, never a redesign) · `active`. + An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); + amendment ruling 2 removed them — there is **no recorded linkage** between a + clone and its base, matching the landed #11513 posture ("an ordinary org-owned + set with no upgrade linkage"). The pin test asserts the column set by EQUALITY + and names both removed columns separately, so re-growing the linkage is loud. + + Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled + as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That + spelling is load-bearing, and the two obvious alternatives are both wrong here: + + - bare `unique: true` on a declared index is the positional spelling of + `'global'` — installation-wide over exactly the listed columns — and is + already warned by lint `unique/unscoped-declared-index` in 17.x; + - a hand-written `['metadata_type', 'name', 'organization_id']` composite is + NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row + by construction, so that index would enforce **nothing at all** (#5030, + measured) and one artifact could carry two contradictory `active` rows. + + `'organization'` is the arm that closes exactly that hole: the driver prepends + `COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which + is what §4's "NULL-collapsed" names. + + The name is also registered in `@objectstack/spec`'s platform-object name census + (`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a + curated set of REAL names, not a `sys_`-prefix pattern, precisely so a + cross-reference check can tell `sys_user` (real) from a fictional + platform-prefixed name; its module contract is explicit that "adding an object + to a platform package means adding its name here", and the owning package's + conformance pin fails otherwise. This is a one-name roster registration, **not** + protocol or schema surface — the ledger remains an ordinary platform object with + no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its + user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` + now answers `true`, so lint stops reading a reference to the ledger as a typo. + + **No behavior change.** This leg ships the declaration only — the enable/disable + actions that write the ledger and the per-runtime consult points that read it + are separate legs, and nothing in the tree reads the object yet. Absence of a + row means the packaged default (**active**), so an empty ledger changes nothing + anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. + The object deliberately declares **no `lifecycle` block** — unlike its telemetry + siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable + configuration, and reaping one would silently re-arm an artifact an + administrator disabled. +- c4db311: `CurrencyConfigSchema` no longer materializes the `precision` default onto a configuration the schema itself would refuse as authored (#11423). A bare `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }` used to parse to `precision: 2` — and the #7918 rule rejects an authored `precision: 2` against JPY's 0 fraction digits, with the materialized and authored spellings indistinguishable by design — so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse: `parse(parse(x))` threw for an input `parse(x)` accepts. + + Mechanism (the #9689 idempotent-materialization ruling, applied to its recorded currency twin): one conditional in the `.overwrite()` — when `currencyMode` is `fixed`, no `precision` was authored, and the currency's ISO 4217 / CLDR fraction digits contradict the default `2` (the JPY/KRW/KWD class), the parsed output OMITS `precision` instead of baking a value the schema refuses. Renderers already derive display width from the currency when the key is absent (objectui#4361), so absent is the honest spelling. Every other combination keeps byte-identical output: an authored `precision` is untouched, a bare fixed 2-fraction-digit config (USD/EUR/CNY…) still materializes `precision: 2` at its shape position, and `dynamic` mode and non-CLDR codes (crypto/custom, fail-open) keep materializing — none of those can be refused. The #7918 rejection of an authored contradictory `precision` is unchanged, message and path included. +- d173125: feat(spec): retire the component-translation `submitLabel` copy key (#10926, 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). + + The `pages..components.` copy face is measured, not mirrored: each + key exists because some component in `ComponentPropsMap` declares it. + `submitLabel`'s only declarer was `element:form`, and #9249 retired that + element whole — so the key had no declared component left to translate, and + the resolver overlay was its only reader. The maintainer ruled retire over + re-anchor (#10926): the live form surface (`object-form`) speaks `submitText` + (`I18nLabelSchema`), localizable at its own authoring site, so re-anchoring + would have widened the face for one word. The acknowledged cost is that the + bespoke-component route loses that one word. + + **What is refused:** `submitLabel` in any `pages..components.` + translation entry, and its `submit` alias spelling — both now land on a + `guidance` prescription in the strict unknown-key rejection (the face is + `.strict()`, so the strict-delete route applies: no `retiredKey()` tombstone, + the shape simply no longer declares the key). + + **What stays:** the other five copy keys (`title`, `description`, `label`, + `placeholder`, `emptyText`), the bespoke-component route for them, and the + shared `PAGE_COMPONENT_COPY_KEYS` list (now five entries) that drives both + `translatePage`'s overlay and the CLI `i18n-extract` skeleton — one list, both + sides import it, so extractor and resolver narrow together. + + The retirement kit: + + - strict-delete at the schema (`packages/spec/src/system/translation.zod.ts`): + key and `submit` alias dropped; `guidance` tombstones carry the prescription + - `PAGE_COMPONENT_COPY_KEYS` drops the slot + (`packages/spec/src/system/i18n-resolver.ts`) — the resolver no longer + overlays the key and the extractor no longer offers it + - ADR-0087 registration: D2 conversion + `translation-component-submit-label-removed` (protocol 18), wired into the + step-18 chain — `os migrate meta --from 17` strips the key from stored + translation bundles and items (pure lossless delete; nothing read it since + #9249) + - pin tests flipped, not deleted (`translation.test.ts` refusal pins assert + the prescription; `i18n-resolver.test.ts` pins that an off-spec bundle entry + carrying the retired key is ignored, not overlaid) + - generated baselines/docs follow the schema (json-schema manifest, + spec-changes, upgrade guide, api-surface signatures, reference docs) + + ## FROM → TO + + ```ts + // before — a component-translation entry could carry a submit label + translations: [{ + 'zh-CN': { + pages: { + sales_home_page: { + components: { new_lead_form: { submitLabel: '创建' } }, + }, + }, + }, + }] + + // after — delete the key (nothing has read it since #9249); submit copy for + // the live form surface is authored on the component itself, where it is + // localizable inline + { + type: 'object-form', + properties: { + objectName: 'lead', + submitText: { en: 'Create', 'zh-CN': '创建' }, + }, + } + ``` +- 8425c17: The five engine members the #11833 sweep measured as "real on ObjectQL, consumed cross-package, recoverable only through consumer-local structural re-declarations" are now on the contract, per the 2026-08-25 maintainer ruling (#12248). `IDataEngine` gains five optional members: `resolveEffectiveDatasource?(objectName)` (the #5288 effective-datasource name, `undefined` = rides the deployment default), `getDriverForObject?(objectName)` (the public driver-routing read, `IDataDriver | undefined`), and the datasource-lifecycle trio `registerDatasourceDef?` / `markDatasourceUnavailable?` (`kind: 'blocked' | 'failed'`, framework#3828) / `clearDatasourceUnavailable?` (#12010's inventory, adjudicated per the ruling's item 4). Engines without datasource routing stay conformant — every member is optional, preserving each graceful-degradation seam. And `IObjectQLEngine.getObject` / `EngineSchemaRegistryView.getObject` now return `ServiceObject | undefined` — the spec's own registered-object type (authored state, ADR-0122) — instead of `unknown`, so consumers reading `fields` / `external` off a registered object no longer need a private structural re-declaration to do it; an engine or registry fake answering a non-conforming shape now fails compile at the member instead of drifting silently (the #4251 gap, closed at this seam). +- 772d5de: feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575) + + **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). + + These were two more instances of the #8691/#8744 silent no-op class: + console-registered widgets on `@objectstack/cloud-connection`'s published + Setup pages, reachable through the component type union's open string arm, + with registered renderers but no `ComponentPropsMap` row — so the #5068 + component-props gate's dispatch skipped them as unregistered and any authored + key rode through every validator in silence. + + The new rows are strict and **empty**, measured from the renderers' actual + read points at the objectui pin, not from the registrations' declared-input + lists (#8691/#8744 record where those diverge — here the two happen to + agree): both registrations discard the schema node entirely + (`() => `, `() => `) and neither + component function takes a prop, so the widgets accept **no configuration at + all**, and an authored key is now a publish-time refusal naming the surface + instead of a silent no-op. + + **What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the + shape both plugin-shipped pages (`cloud_connection_settings`, + `marketplace_installed`) author today, byte-identically. Node-level keys + (`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component + node, and the refusal's guidance says so. + + ## FROM → TO + + ```ts + // before — parsed green everywhere; the panel polls on its own schedule anyway + { + type: 'cloud-connection:panel', + properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing + } + + // after — any key is a publish-time refusal naming the zero-prop surface; + // write the measured shape + { + type: 'cloud-connection:panel', + properties: {}, + } + ``` + + There is deliberately no automatic rewrite: a key authored on either widget + configures nothing and is removed, not renamed — behaviour that seems to need + one is a renderer capability request against objectui, not a metadata key. + `os migrate meta` surfaces the change as a structured TODO (semantic entry + `ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this + refusal is not part of the v17.0.0 cut). + + +- ce80ec2: feat(spec): declare `mcp:connect-agent` in `ComponentPropsMap` — undeclared keys on the widget are refused (#12344) + + **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). + + This was a third instance of the #8691/#8744 silent no-op class (#11575 closed + the previous two): a console-registered widget on `@objectstack/mcp`'s + plugin-shipped Setup page (`CONNECT_AGENT_PAGE`), reachable through the + component type union's open string arm, with a registered renderer but no + `ComponentPropsMap` row — so the #5068 component-props gate's dispatch skipped + it as unregistered, any authored key rode through every validator in silence, + and door 3 of the mcp canonical-envelope gate (#12269) had to carry a standing + exemption for the type (deleted here, with its two guard pins). + + The new row is strict and **empty**, measured from the renderer's actual read + points at the objectui pin, not from the registration's declared-input list + (#8691/#8744 record where those diverge — here the two happen to agree): the + registration discards the schema node entirely (`() => `) and + the component function takes no parameters — every value it renders comes from + `/discovery`, i18n and its own state — so the widget accepts **no + configuration at all**, and an authored key is now a publish-time refusal + naming the surface instead of a silent no-op. + + **What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the + shape the plugin-shipped page (`connect_agent`) authors today, + byte-identically. Node-level keys (`visibleWhen`, `id`, `style`, …) are + unaffected: they live on the component node, and the refusal's guidance says + so. + + ## FROM → TO + + ```ts + // before — parsed green everywhere; the widget reads /discovery on its own + { + type: 'mcp:connect-agent', + properties: { serverUrl: 'https://example.test/mcp' }, // silent no-op: the widget reads nothing + } + + // after — any key is a publish-time refusal naming the zero-prop surface; + // write the measured shape + { + type: 'mcp:connect-agent', + properties: {}, + } + ``` + + There is deliberately no automatic rewrite: a key authored on the widget + configures nothing and is removed, not renamed — behaviour that seems to need + one is a renderer capability request against objectui, not a metadata key. + `os migrate meta` surfaces the change as a structured TODO (semantic entry + `ui-mcp-connect-agent-unknown-keys-refused`, protocol major 18 — this refusal + is not part of the v17.0.0 cut). + + +- b372318: `UserActionsConfigSchema` adopts `group`, `hideFields` and `rowColor` (ruled Option A on objectui#5435): the three toolbar affordances ListView already honours become authorable in a spec-valid document, so the runtime fold of legacy `showGroup`/`showHideFields`/`showColor` flags now passes the save gate instead of being rejected by name. All three are booleans; the defaults copy the renderer's reads — `group` defaults on, `hideFields`/`rowColor` default off. Accept-set widening only: no existing document changes meaning, and the object-level `userActions` block (create/import/edit/delete/exportCsv) still rejects all view-vocabulary keys by name. + +### Patch Changes + +- cae2169: liveness ledger: re-classify `action.execute` `live` → `dead` (#13036) + + The entry claimed a `.transform` that lowers `execute` → `target` and drops the + alias. No such transform exists: `packages/spec/src/ui/action.zod.ts` has exactly + two `.transform` calls and both are `lowerRequiresFeature`. The alias and its + lowering were removed together in protocol 17 (#3855, landed 2026-07-28); the key + has been a `retiredKey` tombstone ever since, and `packages/cli/src/utils/lower-callables.ts` + declines to bind a function-valued `execute` on purpose so the tombstone fires. + + Data-only: no schema, no runtime, no authoring surface changes — authoring + `execute` already failed `tsc` and the parse before this, and still does. The row + STAYS, per the `rls.priority` precedent a `retiredKey()` tombstone keeps the key + in the walked shape, so deleting the row would report UNCLASSIFIED. `liveness/` is + in this package's `files` array, so these ledgers ship in the npm tarball and this + is published data. +- e84bbf6: fix(spec): `ObjectSchema.create()` rejects unknown keys on nested action-param literals at compile time (#12615) + + `create()` infers its generic from the argument, so TypeScript's + excess-property (freshness) checking never fires at any depth; the existing + `NoExcessObjectKeys` map compensated only at the top level. A typo'd key on an + `actions[].params[]` literal (measured: `carryOverX` on + `sys-permission-set.object.ts`) therefore passed `tsc` clean and was caught + only by `ActionParamSchema`'s strict parse at module load. + + The same `Record` map is now mirrored over each element of + each action's `params` array, so the typo becomes a located `tsc` error at the + authoring site (`error TS2322 … 'true' is not assignable to 'never'` pointing + at the unknown key). + + Compile-layer signal only — shipped as `patch` because no working code + changes meaning: the strict parse at module load stays the enforcement of + record, nothing changes in what parses or when, and every literal the new + constraint refuses was already refused (later, at import) by that parse. +- 8bdd955: **Docs:** `APPROVER_ORG_SCOPED`'s docblock stops justifying `team: false` with "the engine never scoped it", which the team organization screen made false (#10548). + + The docblock over `APPROVER_ORG_SCOPED` carried two clauses about `team`, and after #10230 they no longer agreed. "`sys_team_member` carries no organization column" is still true — it is still why a team's *members* are not individually placed. "the engine never scoped it" no longer described the engine: `expandTeamUsers` (`packages/plugins/plugin-approvals/src/approval-service.ts`) opens with `teamIsProvablyOutsideOrg`, which reads `sys_team`'s `organization_id` and drops the team when it names an organization other than the request's, at both of its call sites. + + The **flag value is unchanged and deliberately so**. The table answers ADR-0105 D9 *retargetability* — "does an `organization:` declaration apply to this type" — and `team` still consults no org-scoped directory, so a declaration on it still has nothing to redirect and is still rightly refused by `resolveApproverDirectoryOrg`. Only the justification had drifted, by resting on an engine behaviour that has since changed. The replacement text says the flag is about **targeting, not tenancy**: org-agnostic for retargeting because no directory is consulted, and screened to the request's organization on the team's own `organization_id` regardless. + + The risk repaired is the ordinary one for a load-bearing comment: the next reader deciding whether `team` needs an organization screen would find a spec docblock asserting the engine has none and conclude the work is outstanding when it has landed — or read the `team: false` / `manager: false` pairing as still marking "the unscreened types", which after #10153 and #10230 it does not. + + Prose only: no schema shape, no `.describe()` text, no runtime behaviour, and no authorable-surface movement. It is graded rather than skipped because the text ships to consumers on **two** surfaces — `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts`, so the docblock travels in the npm tarball as source, and unlike a property-level comment inside a `z.object({ … })` literal, a docblock over a top-level `export const` survives declaration emit: the stale sentence is present in the built `dist/automation/index.d.ts` and `dist/automation/index.d.mts`, which is what a consumer's editor surfaces on hover. +- eae824e: docs(spec): anchor the `BatchOptions.validateOnly` tombstone to the release that removed it (#13020) + + Published guidance text only — no accept-set movement. `options.validateOnly` + is still retired, still typed `never`, and still rejected with the same + prescription; the prescription now cites **which release removed it**. + + The tombstone read `… was removed from BatchOptions in @objectstack/spec.` — + a removal with no durable citation. Of the 132 audited tombstone prescriptions + it was the only one left without any: no ADR id, no `os migrate` command, and — + uniquely — no version anchor either. `retiredKey`'s own contract asks for one + ("State what replaced the key, **the version that removed it**, and the + one-line fix"), and 128 siblings carry it. + + The version is READ, never recalled — three independent sources agree on + **17.0.0**: + + | source | reading | + |---|---| + | `packages/spec/CHANGELOG.md` | `ec796d5: feat(spec)!: retire \`BatchOptions.validateOnly\` …` under the `## 17.0.0` Major Changes heading | + | `git log --follow -- packages/spec/src/api/batch.zod.ts` | commit `ec796d5fd`; earliest GA tag containing it is `@objectstack/spec@17.0.0` (no pre-17 spec tag carries it) | + | `packages/spec/spec-changes.json` | `batch-options-validate-only-retired` → `toMajor: 17` | + + The nearest-heading method that produced the changelog reading was run against + two positive controls in the same population first (`global_nav` from + `ACTION_LOCATIONS`, `IDataDriver.findStream`) — both resolve to `17.0.0`, and + both match the version anchor already written in their own source tombstones. + + Anchor spelling byte-matches the sibling convention in the same `api/` family + (`analytics.zod.ts`: `` `query` was removed from AnalyticsQueryRequest in + @objectstack/spec 17.0.0. ``). + + The generated API reference pages under `content/docs/references/` carry the + prescription verbatim and are regenerated by tooling, not by hand. +- f6fa22c: `min`/`max` over a **boolean** aggregand now answer the numbers `0`/`1` on every face — maintainer ruling 2026-08-28 (#11152, option A), superseding #11249's `false`/`true`: booleans aggregate as numbers, with no per-aggregate exception, so one flag column's `sum`/`avg`/`min`/`max` all answer in one numeric domain. + + FROM → TO, per face: `driver-sql` (every dialect, `driver-sqlite-wasm` included via the shared compiler) no longer re-presents `min`/`max` results over a declared boolean as JSON booleans — `false`/`true` → `0`/`1`; row reads (`find()`) still present booleans, and `min`/`max` over an empty window still answer `null`. `driver-memory` (data and analytics faces) and objectql's in-memory fallback compare booleans as the numbers they are worth — `false`/`true` → `0`/`1`; strings, dates and numbers reach the same comparison they always did. `driver-mongodb` wraps `$min`/`$max` in the same boolean-only `$cond` coercion `$sum`/`$avg` use — `false`/`true` → `0`/`1`; null/missing still pass through, so the empty window still answers `null`. A caller reading `min`/`max` over a boolean column as a JSON boolean should read the number (`0` is false-y, `1` truthy, so boolean coercion at the call site keeps working). + + The cross-driver aggregation conformance fixture (`AGGREGATION_ROWS`, `@objectstack/spec/data`) now carries the boolean column those rulings are pinned by: `flag` (3 true / 3 false), with cases for `sum`=3, `avg`=0.5, `min`=0, `max`=1, `count`=6, `count_distinct`=2 and a grouped `min` over the deliberately asymmetric groups — the reach gap #11065 and #11151 were both found through (a boolean aggregand no conformance cell could see) is closed. +- 8a483b3: docs(spec): the last three broken-sweep mentions in `automation` become the first FILTER, and the misspelled-`effect` sentences state the direction the code measures (#13063) + + #12685 measured that `selected > 0 AND acted = 0 AND unmeasured = 0` cannot separate a + healthy idempotent sweep from a dead gate, and #12721 / #12722 / #12900 / #13068 rewrote + the surfaces that stated it as a detector. Each of those changes was fenced to the doc + blocks its dispatch named, so three mentions inside `packages/spec/src/automation` + survived — including one that ships in `.d.ts` and is what a platform author reads. + + Reworded to the shape the sibling surfaces now agree on: the predicate is the FIRST + FILTER and not a verdict, the per-node fold (`FlowRunSummary.nodes[]` / `gates[]`) is the + discriminator, and each clause keeps its own true point. + + - `ExecutionStepMetricsSchema`'s `unmeasuredEffect` rationale no longer says an + understated `0` "fires the broken-sweep alert on a healthy run until operators learn to + ignore it". That muting is not peculiar to an understated `0` — after #12685 the filter + selects every healthy idempotent sweep — so the block now states what a fabricated + count actually costs: an understated `0` puts a run that DID act inside the filter, an + overstated `1` keeps a run that acted on nothing outside it, and a faked `acted` is a + fact the per-node fold can only repeat rather than settle. + + Separately, and measured rather than ruled: the two sentences describing a misspelled + `effect` key stated their consequence backwards. Read forward, a lost `effect: 'writes'` + declaration means the `script` step reports no `unmeasuredEffect`, so the run reports + `selected > 0, acted 0, unmeasured 0` — which SATISFIES the filter. The run therefore + lands INSIDE the candidate set reading exactly like a dead sweep, rather than escaping + it; the declaration is what would have kept it out. That is how the same file's `@module` + block already stated it, and what `flow-function-effect.dogfood.test.ts` asserts end to + end. The `FlowFunctionDeclarationSchema` TSDoc and the author-facing `history` string in + its unknown-key message now say so. + + TSDoc and one error-message string only — no behaviour, no schema, no accept-set change. +- df59de0: `element:button`'s `icon` description now names the shared icon resolver the renderer actually uses + + The `.describe()` on `ElementButtonPropsSchema.icon` — the sentence the docs site and the generated skill artifacts put in front of authors — said the renderer resolved the name "using its own PascalCase normaliser and rename map". That stopped being true when objectui moved the button off its file-local copy of that algorithm and onto the `resolveIcon` helper every `action:*` site already shared. The duplicate had been a defect in its own right: a rename added to the shared resolver to absorb a Lucide icon retirement reached every action site and silently missed `ui:button`. + + Nothing an author may write changed, and nothing about the platform's behaviour regressed. `icon` is still read, still rendered on either side of the label per `iconPosition`, still suppressed while `loading`, and an unknown name still resolves to nothing rather than degrading to a fallback glyph — which is still exactly what separates this slot from the `LazyIcon` path the container icons use. Only the sentence describing where the resolution lives had gone stale, and it is corrected here together with the cross-repo read-point anchors recorded beside it, re-measured at objectui `9602dc820450`. +- f75a38a: fix(spec,repo): address the changesets `repo` option and the spec README's StackBlitz badge to `objectstack-ai/objectstack` (#12488) + + Two places still name `objectstack-ai/spec`, which is not the repository this + monorepo lives in: + + - `.changeset/config.json` — `changelog[1].repo` + - `packages/spec/README.md` — the "Try Online" StackBlitz badge target + + The README badge is the user-visible half. `README.md` is listed in this + package's `files`, so it ships in the npm tarball and every reader of + `@objectstack/spec` on npm gets a "Try Online" button addressed to a repository + that is not this one. The path the badge names — + `examples/app-todo/objectstack.config.ts` — exists here, so correcting the + owner/repo segment is the whole fix. + + The `.changeset/config.json` half is hygiene with a measured expiry date rather + than a bug that is firing. The configured generator `@changesets/cli/changelog` + re-exports `@changesets/changelog-git`, whose `getReleaseLine(changeset)` and + `getDependencyReleaseLine(changesets, dependenciesUpdated)` do not read the + third argument — the options object that `@changesets/apply-release-plan` + passes as `config.changelog[1]`. So `repo` has **no reader today**, and the + wrong value has never produced a wrong link in any CHANGELOG this repo has + generated. It acquires a reader the moment anyone swaps in + `@changesets/changelog-github` — which is the usual reason to touch that block + — and from then on every generated release line would link into the wrong + repository, with nothing in the diff looking wrong. + + No runtime behaviour changes. +- 1fa05a6: fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) + + TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by + building and reading the emitted declarations), so they reach consumers directly in their + editor's hover tooltip — copying one is the intended usage. Two were actively wrong: + + - `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or + `null`), which has no `.map` — copied verbatim, this throws once the query resolves. + Fixed to `data?.records.map(...)`. + - `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not + exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real + `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. + + While in there: every other `@example` on this surface (18 more, across + `client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` + and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each + previously omitted the `import` for the hook or type it demonstrated, and three + `realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose + placeholder, a syntax error once copied. + + **`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now + type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in + this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish + `files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface + moves. Named here only because the fixed-version group requires every package with a + source diff to be covered by a changeset; the actual version bump is a byproduct of the + group moving together, not a claim that spec shipped something new. + + No exported type, function signature, or runtime behaviour changed on any of the three — patch. +- dcb10a5: fix(spec): `COMPONENT_NODE_VISIBILITY_GUIDANCE` no longer claims a hoisted `properties` visibility key is evaluated by nothing (#11033) + + The `COMPONENT_NODE_VISIBILITY_KEYS` key-set guard's `prescription` — the text + emitted to an author when a visibility key (`visible` / `visibleWhen` / …) is + written inside `properties` instead of on the component node — closed with: + + > Inside `properties` it is hoisted onto the node by the renderer but evaluated + > by nothing — the component renders unconditionally, which is a visibility + > gate that silently does not gate. + + That was true when it was written and is false since objectui#5505 + (`c86185eb5`, merged 2026-08-21): `SchemaRenderer`'s node-level `visibleWhen` + evaluator now binds `record`, so the hoisted value IS evaluated by the + node-level gate. Post-#5505 the props-level and node-level forms evaluate the + same value over the same `RecordContext` and compose as an idempotent AND — + there is no gate that silently fails to gate. + + The prescription now states that truth instead, and keeps its move-it-up + advice resting on the reason that still holds: `visibleWhen` at the node, + beside `type` and `id`, is the ADR-0089 canonical spelling — a layer-discipline + argument, not an inertness one. + + Message text only. No accept/reject verdict changes, no schema shape changes, + and no runtime behaviour changes — both gates already evaluated the value + identically before and after this change. +- 776a098: docs(spec,service-datasource): `CryptoContext` documents the three producer vocabularies, and the AAD sentence narrows to the guarantee that holds (#12599) + + Documentation-only correction to the `ICryptoProvider` contract. No behavior + changes and no schema shape changes: `gen:schema`, `gen:openapi` and `gen:docs` + all regenerate byte-identically over this diff. It ships because the corrected + TSDoc is emitted into the published `.d.ts`, so it is what every consumer of + `@objectstack/spec` reads at the call site. + + `CryptoContext.namespace` / `.key` documented themselves as a settings + coordinate ("Settings namespace the value belongs to" / "Specifier key within + the namespace"), one layer below the three producers that actually construct + the type: + + | producer | `ctx.namespace` | `ctx.key` | where `handle.id` is recorded | + |---|---|---|---| + | `SettingsService` | settings namespace | specifier key | `sys_setting.value_enc` | + | the ObjectQL engine's secret-field path | **object name** | **field name** | a `secret:` ref on the business row | + | the datasource secret binder | caller-supplied, default `datasource` | datasource name | a `sys_secret:` credentialsRef | + + The prose now names all three, and `CryptoHandle.id`, `encrypt()` and + `rotateKey()` no longer describe `sys_setting.value_enc` as the general + destination of a handle. + + The load-bearing half is the AAD sentence. It read "Helps reject ciphertexts + that were copied across namespaces", which overstates what a binding over this + pair can provide: `(namespace, key)` is **one flat space shared by all three + vocabularies**, `sys_secret` declares the pair non-unique by design, and nothing + reserves a name in one vocabulary against another. The sentence is replaced by + the guarantee that actually holds — such a binding rejects a ciphertext swapped + between two coordinates *within one producer's vocabulary*, and does **not** + exclude a cross-vocabulary pair. The docblock records this as the contract's + present state and names the intended end state (a producer-discriminated AAD) + so the weak guard is not read as the designed one. + + The same settings-only prose on `DatasourceSecretBinderDeps.namespace` + ("Settings namespace recorded on the secret row") is corrected in the same + change. + + Recorded under the maintainer ruling of 2026-08-27 on #12599 (Option A now, + with the producer-discriminated AAD recorded as direction in its own ADR). +- 7c0d0c3: fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) + + `api.enableSearch` was a live REST config key with no declared seat: + `RestServer.normalizeConfig` read it through `(api as any)` and honoured it + (`enableSearch: false` really unmounted the search endpoints), but no schema in + `packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, + its own parse **stripped** the key — measured: + `RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an + object with no `enableSearch` property at all — so any consumer of the parsed + config silently got search turned back on for a deployment that turned it off + (the ADR-0104 silent-strip class). It also forced #11637's construction-time + parse to be validation-only, discarding the parsed value. + + - `RestApiConfigSchema` now declares + `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the + runtime's existing default. The opt-out now survives the key's own + contract's parse (pinned), and a TypeScript author can write + `api: { enableSearch: false }` without a cast. + - `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the + newly declared `enableSearch`, the already-declared + `metadata.maskObjectFields` (its declared seat landed separately; the cast + was stale), and the long-declared `enableOpenApi` (stale residue from + before its declaration). `NormalizedRestServerConfig.api.enableSearch` is + now a required boolean like its siblings. + + No runtime behavior changes: defaults are identical (`enableSearch` on, + masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from + cast-reachable to declared = enforced. +- e25403c: `DELIVERY_NOT_ELIGIBLE`'s ledger gloss now describes every surface that raises it, not just `redeliver` + + The `ERROR_CODE_LEDGER` entry for `DELIVERY_NOT_ELIGIBLE` glossed the code as + *"delivery row is in a non-terminal state"*. That named one refusal on one + surface, and the code has since been reused on a second: `INotificationOutbox.ack` + refuses any row that is not `in_flight`, which covers an unclaimed `pending` row + **and** an already-terminal one. So the old wording was not merely incomplete — + it was backwards for half the code's uses, describing terminal rows as the + acceptable ones when `ack` refuses exactly those. + + The reuse itself is the ruled shape, not a defect: one concept — *this delivery + row's state does not permit the requested operation* — on two delivery surfaces, + with a second near-synonym code rejected for the vocabulary sprawl ADR-0112 + exists to prevent. Only the comment lagged. + + The gloss is now stated per surface, because the two refuse opposite halves of + the state space and no single status predicate covers both: + + - **`IHttpOutbox.redeliver`** (`HttpRedeliverError`) refuses a row that is NOT + terminal — `redeliver` means send this again, so it wants + `success`/`failed`/`dead`. It also raises the same code when the producer's + `RedeliverGuard` refuses or itself throws (fail-closed: "we could not check" + must never read as "allowed"), and when the terminal re-check at the write + misses because a dispatcher tick re-claimed the row mid-call. + - **`INotificationOutbox.ack`** (`NotificationAckError`) refuses a row that is + not `in_flight` — both the unclaimed `pending` row (the ack-as-cancel trap) + and the already-terminal one — plus the `SqlNotificationOutbox` compare-and-set + read-back that shows the claim was lost mid-ack. + + Comment only. No code is registered or removed, no wire value changes, and no + acceptance or refusal behaviour moves — `packages/spec` publishes + `src/**/*.zod.ts`, so the corrected gloss ships to consumers reading the ledger. +- 64baa68: fix(spec): the dist-freshness refusal derives its package label and build remedy from `pkgDir` (#11250) + + `inspectDistFreshness()` / `inspectBundleFreshness()` in `packages/spec/scripts/lib/dist-freshness.ts` + already take an arbitrary `pkgDir`, but their refusal `cause` strings and the `pnpm --filter build` + remedy line hardcoded `packages/spec` / `@objectstack/spec` regardless of it. Every real caller passed + `SPEC_DIR` until #10969 gave `check:skill-examples` a second surface (`packages/client-react` / + `packages/client`) — confirmed live: a stale-dist refusal on that surface named `packages/spec` while + `packages/spec` was freshly built and `client`/`client-react` were the actually-unbuilt packages, so + following the printed remedy verbatim rebuilt an already-fresh package and re-red identically. + + Both cause strings now interpolate a `packages/` label derived from `pkgDir`'s own path (falling + back to the raw path when the shape doesn't match), and the build-remedy line now reads + `package.json#name` from `pkgDir` (falling back to the same label when it's missing or unparsable). The + freshness verdict itself, and every caller's own `rerun` argument, are unchanged — this is diagnostic + text only. +- 9fa70d7: fix(spec): the generated docs category index cards the pages its `meta.json` declares (#11260) + + `content/docs/references/security/index.mdx` carded four of the five pages the + `meta.json` beside it declares. The fifth, `misc`, was generated and routed in + the sidebar — and unreachable from the one page whose job is to reach it. + + Both files come out of one `gen:docs` run, from two enumerations of "the pages + of this category" that disagreed about exactly one bucket: + + - `meta.json` was built from the pages the run **emitted**, which is where a + published schema that no `.zod.ts` accounts for lands (`security/` declares + two in plain `.ts` files, so they fall to the `misc` catch-all); + - the card grid was built from the `.zod.ts` files **on disk**. + + `misc` has no `.zod.ts` behind it by definition — the generator says so twice, + and `sourcePathFor` returns nothing for it precisely so the page prints no + invented "Source:" line — so it was *structurally* absent from the second + enumeration. The card loop never considered it, which also means the + `wasEmitted` guard that the loop's own comment leaned on ("This aligns the + index with `meta.json`") never ran for it. The comment was wrong in the shape + that reads as verified: it named the invariant while the code held it by + coincidence, for the 13 categories where two independent enumerations happen to + agree. + + The grid now iterates the list `meta.json` was built from, and keeps the + `wasEmitted` guard — a `.zod.ts` whose schemas are all unrepresentable in JSON + Schema still cannot be carded into a dangling 404. Because both files now read + one list, that guard can no longer thin the grid silently: a declared page the + run did not emit stops the build naming it. The stated invariant is true by + construction rather than by coincidence, which closes the class instead of + special-casing `misc`. + + The regenerated output is one line: `security/index.mdx` gains its `misc` card + (with no "Source:" line, correctly). The other 13 category grids are + byte-identical. + + An all-`misc` category — every published schema in the catch-all — would have + rendered an **empty** `` grid under the old loop, with every zod-derived + slug filtered out and `misc` never considered. No such category exists in the + repo, so no emitted file can pin it; the rule moved into + `scripts/lib/category-index.ts` so it can be asserted directly, and an empty + grid is now unreachable from a non-empty declaration. +- 09db64a: **Docs:** removes stale GraphQL references and stale hand-typed header provenance from generated and hand-kept protocol docs (#10834, #10833). + + GraphQL was retired as a product surface some time ago: `packages/spec/src/api/` has zero GraphQL sources, the `/graphql` HTTP route was removed from the dispatcher (out of the product plan, #2462 follow-on), and `graphql` was never actually a `CoreServiceName` — it only ever existed as a stray entry in this table and in metadata-protocol's discovery table (see the comment above `SERVICE_PROVIDER_TABLE` in `core-services.zod.ts`). Two places in the package still asserted otherwise: + + - The generated `content/docs/references/index.mdx` API Protocol blurb read "REST/GraphQL contracts, …". The source is `CATEGORY_BLURBS.api` in `packages/spec/scripts/build-docs.ts`; fixed there and regenerated with `gen:docs` — no hand-edit to the generated `.mdx`. + - The hand-kept `packages/spec/llms.txt` (no generator; ships in the npm tarball per `files`) listed an `IGraphQLService` contract (execute, subscribe) under Service Contracts. `IGraphQLService` is declared nowhere in `packages/**/src` — verified before removal. Deleted the row rather than marking it `**DEPRECATED**` like the neighbouring `IUIService` row: that precedent fits a contract that has a replacement to point readers at; GraphQL has none — it's out of the product plan, not superseded by another contract — so a deprecation note would invent a migration path that doesn't exist. + + Also dropped this file's hand-typed `Schema Count` / `Last Updated` header lines (`171 Zod schemas, 191 test files, 5,157 tests`, `2026-02-12`) rather than refreshing them. Measured against the current tree: `packages/spec` now publishes 1,585 schemas (per the freshly generated `content/docs/references/index.mdx` root index) across 418 `*.test.ts` files — both roughly an order of magnitude past what the header claimed. Since this file has no generator (confirmed by the filer) and nothing re-verifies these numbers on change, a refreshed count would start drifting again on the very next PR that touches the package; removing the assertion is more honest than restating a number this file has no mechanism to keep true. Whether `llms.txt` should be generated at all is a larger follow-up left to the PM, not decided here. + + Graded rather than skipped: `llms.txt` ships in the `@objectstack/spec` npm tarball (`files`, enforced by `check:published-files`), so this prose change reaches consumers the same way the precedent in #10669 (`skill.tools` docblock) did. +- 92916e7: fix(spec): reference pages carry every variant's `.describe()` text when a property opens more than one object shape (#12316) + + The designed-and-measured remainder of #11601. #12309 gave a property that + opens exactly ONE nested object shape a `### Nested Shape:` table, and refused + a property whose type is a union of TWO OR MORE object shapes — there was no + single "the shape of this property" for a heading to name, and naming one + would have meant a variant index, i.e. a second addressing notation. Those rows + kept the collapsed signature cell, and the cell has no description column, so + their nested `.describe()` text stayed exactly as unreachable as #11601 found + it. + + **The population, re-measured on `origin/main@7bd6447`.** 28 of the 8604 + rendered property rows open two or more shapes, and **all 28** carry describe + text on at least one variant: `ui/App.navigation`, + `ui/NavigationArea.navigation` and `ui/NavigationContribution.items` with nine + variants each, `data/ConditionalValidation.then` / `.otherwise` and + `system/CRDTMergeResult.state` with five, `system/ChangeSet.operations` / + `.rollback` with seven, down to four two-variant rows. The census is unchanged + from the one #11601 recorded when it deferred them. + + **What is rendered now.** One sub-table per variant, each under the same + `### Nested Shape:` heading in the same position — no new grammar, and no new + heading level, so the single-h1 invariant and the module-header numbering are + untouched. The accessor gains a SELECTOR segment, spliced in where the union + sits in the wrapper stack rather than appended to the finished path, so + `StateMachine.on[string][option 3][number]` reads left to right as *the record + value, its third option, an element of it* — the reading `[string][number]` + already had. + + Two spellings, in preference order: + + - **`[type='sidebar']`** where the union has a discriminant — a key every + shape-bearing variant pins to a *different* literal. It states what the + author writes to select that variant, in the same `formatLiteral` spelling + the Type cell prints two lines above, and it is stable: reordering the union + or adding a tenth variant moves no existing heading and breaks no existing + anchor. 22 of the 28 rows have one. + - **`[option 2]`** for the six that do not — deliberately the word the union + branch has printed under `### Union Options` since long before this, rather + than a bare `[2]`, which in a stack of `[number]`/`[string]` segments would + read as a tuple index into the property's own type. It counts position in the + union *including* arms that open no shape, so the number is checkable against + the `string | { … } | { … }[]` cell the table sits under. + + The distinctness half of the discriminant test is load-bearing: a union whose + arms pin the same `const` has no discriminant by this rule, because answering + one would emit two identical headings — two identical anchors on one page, the + defect the `Schema.key` qualification exists to prevent. + + Every bound #11601 set still holds. Depth stays at `SHAPE_DEPTH_LIMIT = 1` — + what was lifted is the multi-shape *refusal* at level 1, not the budget, so a + shape nested inside a variant is exactly as unreachable as it was inside a lone + shape. "Only where there is text to publish" is now decided **per variant**, + which is the same rule one level finer: `ui/FormView.submitBehavior` opens four + shapes and one carries prose, so it gets one table, not four. And a variant + table still relocates no vocabulary — it is a third position for those keys. + + A property opening exactly one shape gains no selector segment, because the + stamp is conditioned on the union's own yield and not on the row's: all 1469 + single-shape headings #11601 published are byte-identical. The regenerated tree + is **purely additive** — 15 files, **+1397 / -0** lines, no reordering. +- a84f3ea: fix(spec): reference pages carry a nested item shape's `.describe()` text instead of collapsing it into a signature cell (#11601) + + `build-docs.ts` renders a property whose type is an inline object as a + one-line signature — `{ label: string; icon?: string; visibleWhen?: string | + object; value?: string; … }[]` — into a table cell that has **no description + column**. Every `.describe()` an author wrote on a key of that shape was + therefore unreachable from the reference page: not truncated, not marked, + absent. `page:tabs`'s item-level `visibleWhen` carries a ~600-character + contract note whose whole point is that its evaluation environment is **not** + the page-component `visibleWhen` of the same name, and + `content/docs/references/ui/component.mdx` rendered that row with an empty + Description cell. + + The loss was invisible from both sides. `check:docs` compares generated output + with committed output, so it is green forever on prose neither side contains — + measured on the tree before this change, adding a `.describe()` to a nested + item key produced a **zero-line** `gen:docs` diff. + + **The population, measured on the emitted tree.** 1293 property rows across + 566 published schemas and 13 of 14 categories open a nested shape; 1208 of them + have at least one key carrying describe text, 7502 described keys in total, + ~473 KB of authored prose that reached no page. + + **What is rendered now.** A property that opens exactly one shape, and whose + shape has at least one described key, gets a `### Nested Shape:` table directly + under the Properties table — the same position, addressing and heading level + the `### Allowed Values:` relocation has used since #6225, so the page gains no + second grammar. The heading names the shape with a TypeScript indexed accessor + (`PageTabsProps.items[number]`, `Object.fields[string]`), which is a real + spelling rather than a sigil invented for the docs. + + Four bounds, each measured rather than chosen: + + - **One level**, matching the `SHAPE_DEPTH_LIMIT` budget a cell already spends. + A nested table opens no table of its own. + - **Only where there is text to publish.** A shape whose keys carry no + describe text keeps its cell; a table there would restate the cell in more + space. + - **A union of two or more object shapes keeps its cell.** There is no single + "the shape of this property" to name — the same reason `formatPropertyType` + refuses to relocate a vocabulary out of `Enum<…>[]`. + - **A nested table does not relocate vocabularies.** It is a second position + for those keys, so it elides them the way a `{ … }` summary does. Without + this rule the 288-member `ApiError.code` vocabulary was re-listed under every + nested `error` shape — 20,260 bullet lines across the tree, `api/metadata.mdx` + alone +6097. + + Tombstoned keys are rendered in a nested table, unlike in the cell above it: + `retiredKey()` puts the whole `[REMOVED]` migration prescription in + `description`, and a signature has no column to carry it. + + The regenerated tree is **purely additive** — 143 files, +14195 / -118 lines, + and every one of the 38177 pre-existing lines is still present byte for byte + (the 118 are re-ordering around the inserted sections, not removal). +- f2eaae8: fix(spec): the generated docs root sidebar lists categories from the same declared page list `meta.json` and the category index already agree on (#11482) + + `build-docs.ts` writes the docs tree from what should be one answer to "which + categories/pages exist", but the ROOT `content/docs/references/meta.json` (§3 + — the sidebar's top-level category list) was still answering it a third way: + + - a category's own `meta.json` (§2) is built from the pages the run + **emitted**; + - that category's `index.mdx` card grid (§2.5) reads the SAME declared list + (#11260) — no longer a second, independently-derived enumeration; + - the root `meta.json` (§3), until now, filtered on `categoryZodFiles` — the + `.zod.ts` files found **on disk** — a third, independent enumeration. + + A category whose published pages all come from plain `.ts` files rather than + `.zod.ts` ones (the `misc` catch-all class `security/misc` proves is real) has + zero `.zod.ts` files while still publishing a page, a `meta.json` and an + `index.mdx`. The old filter would drop such a category from the sidebar even + though it is fully generated and routed everywhere else — a folder complete on + disk and unreachable from the nav. + + **No category is in that state today** — all 14 have at least one `.zod.ts` + file — so this was a latent defect with no live instance, and the regenerated + root `meta.json` is byte-identical. The filter now reads `categoryMetaPages`, + the same map §2.5 already reads, so all three files answer from one list + instead of three that happen to agree today. The rule moved into + `scripts/lib/root-meta.ts` (`rootCategoryDirs`), pinned directly with the + all-`misc`-category shape that has no instance in the repo — the same move + #11260 made for the category card grid, for the same reason: the defect's + output is an ABSENT sidebar entry, which `check:docs` cannot see any more than + it could see an absent card, and the edge that has no live instance cannot be + pinned from emitted output at all. +- e25e839: liveness ledger: re-classify `field.conditionalRequired` `live` → `dead` (#13043) + + Both halves of the entry's evidence were falsified, not just its citation. It + claimed a `FieldSchema .transform` lowering `conditionalRequired` → `requiredWhen` + that drops the alias: `packages/spec/src/data/field.zod.ts` has **zero** + `.transform` calls — its only two occurrences of that token are comments recording + that `.overwrite()` was chosen instead (#6926). And its note leaned on + `packages/objectql/src/validation/rule-validator.ts` keeping a + `requiredWhen ?? conditionalRequired` fallback "on purpose"; #3903 retired that + fallback, because every path that hands the validator a stored field definition now + replays the ADR-0087 conversion chain at rehydration, so a pre-17 row arrives with + the alias already lowered. The absence is pinned by that module's own test ("does + NOT read the retired conditionalRequired alias — PD #12, no dialect fallback"). + + The alias and its lowering were removed together in protocol 17 (#3855, PR #3883, + landed 2026-07-28); the key has been a `retiredKey` tombstone ever since. + + Data-only: no schema, no runtime, no authoring-surface change — authoring + `conditionalRequired` already failed `tsc` and the parse before this, and still + does. The row STAYS: per the `rls.priority` precedent a `retiredKey()` tombstone + keeps the key in the walked shape, so deleting the row would report UNCLASSIFIED. + `liveness/` is in this package's `files` array, so these ledgers ship in the npm + tarball and this is published data. + + This was the last `path:NNN` citation in any ledger. Retiring it takes the gate's + line-citation counter to zero and closes #13003's symbol-anchor adoption worklist, + which is why the same change deletes the two non-vacuity floors that guarded that + population (and their guard comments) exactly as those comments instructed. +- 8b13cc8: The field designer no longer offers a `master_detail` a `deleteBehavior` the schema refuses + + #9689 made `deleteBehavior: 'set_null'` authored on a `master_detail` field a + named parse-time rejection. The two shipped metadata forms that let a Studio + author pick that value did not move with it, so the designer kept offering + "Set null" for a `master_detail` — the author picked it and learned only at + publish, from a 422, that the choice was never legal. Declared-vs-enforced, one + seam earlier than the parse door. + + Both forms failed the same way through **different option sources**, which is + why the repair is not one edit twice: + + - `object.form.ts` declared an inline `options` array carrying all three values + behind a single `visibleWhen` that named both `lookup` and `master_detail`. + - `field.form.ts` declared **no** `options` at all. That is not a narrower + offer — it is the renderer's derived source: with no inline list the + metadata-admin form falls through to the JSON Schema `enum`, which is + `['set_null','cascade','restrict']` and additionally advertises + `default: 'set_null'`. A Zod enum has no per-type narrowing to give, so the + derived path re-offers the refused value by construction, and `master_detail` + can only be served by an explicit list. + + Both now declare the control **twice, with disjoint `visibleWhen`** — `lookup` + keeps all three outcomes, `master_detail` is offered `cascade` and `restrict` + only. Nothing is taken away from `lookup`, where all three remain legal: + `object.form.ts` keeps its three-value list unchanged, and `field.form.ts`'s + `lookup` branch keeps deriving from the enum exactly as before, so its labels + and their translation are untouched. The two branches are mutually exclusive, so + no author ever sees both. + + **Per-option `visibleWhen` was the tighter-looking spelling and was measured and + rejected.** `SelectOptionSchema` does declare it (ADR-0068), so it reads as + existing vocabulary, but it is not reachable from a metadata form in either + direction: the metadata-admin renderer maps `fieldSpec.options` straight to + select items and never consults it, so writing one would ship an ADR-0049 + declared-but-unenforced key; and on the runtime surface that *does* honor it, + the per-option evaluator binds `record` and never `data`, so a `data.`-rooted + predicate there is an unbound identifier whose visibility fails **open**, + keeping the option. Either way the author would still have been offered + "Set null" — a fix that silently does nothing. Field-level `visibleWhen` is the + predicate the renderer already evaluates for every other type-conditional + control in these files, so the split uses it and the form DSL is unchanged. + + The pinning test asserts more than the absent value: it requires **exactly one** + visible control per type, because two declarations of one key can fail by + overlapping (two selects writing one key) as well as by leaving a gap (the + control vanishes for that type). +- f887e52: Re-measure four stale `current_user` binding-text sites, including the form SECTION slot + + The claim that `current_user` is unbound on a form-view **section** predicate was true when + it was written and is not any more: the console form renderer threads the host shell's + predicate scope into `isSectionVisible` (objectui#6110), and the object-view chain now + carries an authored `section.visibleWhen` through to an evaluator via the `section-divider` + pseudo-field (objectui#6111). Text only — no schema, no verdict and no runtime behaviour + moves. + + - `FormSectionSchema.visibleWhen` (`ui/view.zod.ts`) — the JSDoc and `describe()` now say the + root resolves, carrying the two qualifications the field-slot text already carried: the + binding is **client-side only** (no write-path evaluator reads a form-view section or field + `visibleWhen` — the rule validator's list is field `readonlyWhen` / `requiredWhen` and + per-option `visibleWhen`), and the scope is **empty on the public `/f/:slug` route**, which + is mounted outside any provider on purpose. The `features.*` refusal sentence is unchanged: + that root is unbound on both standalone form routes. + - `SelectOptionSchema.visibleWhen` (`data/field.zod.ts`) and + `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`) — the retired + exclusivity claim ("the one `*When` surface where `current_user` resolves") is trimmed. The + durable grounding stays and is now what the prescription rests on: per-option is the one + visibility predicate the **server** enforces, so the rule validator refuses a write of a + value whose predicate is false. + - `@objectstack/lint`'s field-rule message — the `visibleWhen` consequence clause is + re-measured. Under a scope-publishing host the predicate no longer faults: it resolves, the + control is hidden client-side, and the server still returns the value to every other reader + — a silent enforcement gap. The fault-open leg survives wherever no host publishes a scope. + The verdict is unchanged and the message says why it is now *more* justified: trading a loud + lint error for a gap nobody can see is worse than the error. +- 3bfa1e6: Fix four self-contained TSDoc `@example` blocks in `packages/spec/src` that did + not compile against the package's own built declarations, and add `os:check` + markers so `check:skill-examples` keeps them honest going forward. + + The package's front-door header (`src/index.ts`) taught a root namespace-import + style (`import { Data, UI, System, Auth, AI, API } from '@objectstack/spec'`) + the root has never exported, and two subpath-import styles that named + `@objectstack/spec/auth` — a subpath that does not exist (the real one is + `@objectstack/spec/identity`). The header now documents three styles that + actually compile: root-level `defineX` factory imports (`defineStack`, + `defineView`, `defineApp`, `defineFlow`, `defineAgent`, `defineTool`, + `defineSkill`, …, which the root really does export), namespace imports via + subpath, and direct subpath imports — all against `@objectstack/spec/identity` + instead of the non-existent `/auth`. + + `src/shared/branded-types.zod.ts`'s `@example` imported `ObjectNameSchema` / + `FieldNameSchema` from the package root; both are exported only from + `@objectstack/spec/shared`. Fixed the specifier. + + No behavior change — TSDoc examples only. +- 901355c: `translatePage` now resolves copy for components nested in a container's declared `properties.children` array, recursively — previously it visited region-level components only, so copy authored under `pages..components.` for a nested id parsed happily and was never applied (measured: four KPI labels nested in a `page:card` stayed English on an otherwise fully translated page). + + The published translation face does not change; the resolver widens to match the face it already accepted. When an id appears more than once, a region-level component carrying it wins outright, and among nested components the document-order first match takes the entry. The descent follows `children` only — `body` remains a renderer-side back-compat spelling, not an authoring key — and is depth-capped and cycle-safe, since `children` is authored data. +- 3a04b01: docs(spec): state that identifier length ceilings are storage-owned (#12144) + + The shared identifier schemas (`SystemIdentifierSchema`, + `SnakeCaseIdentifierSchema`, `EventNameSchema`) declare `.min()` plus a + grammar and no `.max()`. That absence is now a documented contract decision + rather than an accident: the enforced ceiling on an identifier is the + `maxLength` of the column that stores it (refused at the write seam by + ObjectQL's record validator), and the storing columns disagree — 100 for + `sys_permission_set.name` / `sys_position.name` / `sys_capability.name`, + 255 for `sys_metadata.name` — so no single shared `.max()` can equal every + consumer's enforced ceiling. No accepted value changes. + + A pin test in `@objectstack/plugin-security` + (`identifier-storage-ceiling-pin.test.ts`) links the spec schemas to the + storage columns that bound them, reading the widths off the registration + surface (the PR #12143 idiom), so the two cannot drift silently: a `.max()` + landing below a storing column's width, or a column width change, turns a + named test red with re-derivation instructions. +- b9e9227: Declare `mappingName` on `ImportRequestSchema` (and therefore on the aliased + `CreateImportJobRequestSchema`). Both import routes already accepted it on the + wire — `prepareImportRequest` resolves the named `mapping` artifact and refuses + `mappingName` plus an inline `mapping` with `400 CONFLICTING_MAPPING` — but the + published contract could not express it, so the typed SDK call + `client.data.import(object, { mappingName: '…' })` was a TS2353 compile error + (#10330). The key is now declared with the same mutual exclusion as a schema + `.refine()`, so a conflicting pair is rejected at authoring time as well as by + the route. Additive only: the schema is a plain `z.object` that strips unknown + keys, so no existing caller changes behavior. +- d395692: Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two + introspection declarations to the measured emitted types (#11122, maintainer + ruling 2026-08-23, option B — 「其他同意你的意见」). + + The spec's introspection contract (`schema-diff-service.ts`) declared + `indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted + it — a consumer typed against the promise read `undefined` with no compiler + complaint. It also declared `defaultValue?: string` while the in-tree SQL + driver passes `knex.columnInfo().defaultValue` through raw (measured on live + SQLite: `null` for a column with no default, dialect-quoted strings such as + `'abc'` otherwise; other producers report native values such as `true`). + + - `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: + an absent key means the producer did not read indexes; an empty array is a + positive claim the table HAS none. Producers that did not look must omit + the key rather than emit `[]`. Wiring the index read into + `introspectSchema()` is explicitly NOT part of this change. + - `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow + before use instead of trusting a string promise no producer kept. + - The SQL layer's extra `maxLength` fact (driver-sql / objectql + `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to + `number | string` — SQLite reports the string `"255"` where other dialects + report a number. + + With the spec now telling the truth, the deliberate `Omit` workarounds in + `@objectstack/driver-sql` and `@objectstack/objectql` (which carved + `defaultValue` and `indexes` out of the spec types to keep the divergence + visible) are retired: both packages' introspection types now extend the spec + contract directly. + + Consumers that read `table.indexes` must guard for absence (none exist + in-tree — the requirement was never honored, so today's readers would have + crashed on `undefined` anyway); consumers of `defaultValue` must narrow from + `unknown` before string operations. +- 5894d30: Surface the email-invite entry on the organization record page's default + Members tab, and stop it rendering as a twin of "Add Member" + + The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on + tab-0 **Members**, whose related-list toolbar carried exactly one action — + `add_member`, which attaches an **already-registered** user by id. The + email-invite entry, `invite_user`, was declared only on `sys_invitation` and + `sys_user`, so it appeared only on tab-1 Invitations. An admin looking to + "invite a teammate by email" landed on Members, found no invite affordance and + concluded the product had none. The delivery half worked the whole time + (`sendInvitationEmail`, template `auth.invitation`) — only the door was in + another room. + + `sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of + `add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same + email + role inputs, and the same `requiresFeature: 'organization'` capability + gate as the other two mirrors. Declaration order is render order in the + related-list toolbar bridge, so the invite button sits left of the attach one. + + **The `email` param names `objectOverride: 'sys_invitation'`, and must.** + `sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` + declaration would leave the param unresolvable — the renderer answers that with + a `type: 'text'` fallback labelled by the raw field name, which still submits + and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no + override: `sys_member` declares it, from the same + `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now + holds this over **all three** mirrors, so the next copy of any action cannot + reintroduce the shape. + + `add_member` keeps its behaviour and its label and is differentiated only in + chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing + record" icon `sys_account`'s `link_social` already uses) — so the two buttons + no longer render as identical primary `user-plus` twins. Both halves are + honoured by the renderer: it draws `primary` filled and every other variant + outlined. + + The `@objectstack/spec` half is one line of registry bookkeeping: + `PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as + it already books the other twelve. No schema, export or authorable key changes. +- a3765f6: docs(spec): state `IPubSub`'s real delivery guarantee on the interface docblock (#12651) + + The `IPubSub` contract docblock claimed **"At-least-once delivery; handlers MUST + be idempotent"**. No shipped driver provides that, and the repo's own measured + statements said so elsewhere: `content/docs/kernel/cluster.mdx` §4.2 ("**No + shipped driver provides this yet.**"), `@objectstack/service-cluster-redis`'s + `publish` docblock (plain Redis pub/sub — at-most-once, fire-and-forget, no + persistence, "acceptable **only** for events that are pure cache-invalidation + hints, never the source of truth"), and the `authz.invalidated` channel module + in `@objectstack/core`, which recorded the contradiction inline rather than + resolving it in the wrong direction. + + The interface docblock is the load-bearing one for the hazard it invites: it is + what a consumer's editor shows at the call site, before they write a subscriber. + "At-least-once" tells that author their only obligation is to tolerate + **duplicates**, while the transport's actual failure mode is the opposite — a + **lost** message, with no replay and no upper bound on how long a node that was + down at publish time stays wrong. An author who designs for duplicates and not + for loss has designed for the wrong hazard, and neither the type nor the tests + contradict them. + + The docblock now states the shipped guarantee **driver-relatively** — delivery + is whatever the configured driver declares, and no shipped driver exceeds + at-most-once — so a future durable driver lands without rewriting the contract. + It says a missed message is expected, requires handlers to be idempotent **and** + loss-tolerant, puts the staleness bound outside the bus (a TTL, a reload, a + durable outbox of its own), and points at `deliverySemantics` as the per-channel + surface while naming what that surface is: what a channel *asks* for, not what + it gets. The existing driver-reach sentence (memory = synchronous in-process; + remote drivers cross nodes) is unchanged — it was never part of the false claim. + + Prose only. No schema, no type, no accept/reject behaviour changes; the shipped + `.d.ts` carries the corrected text to consumers' editors, which is the whole + point of the repair. +- 6acb11a: **Liveness-ledger verdict:** `view.list.map` (`ListViewSchema`'s view-level `map` block) moves `planned` → `live` (#11442). + + objectui#5908 landed the missing half of the forward: `packages/plugin-list/src/ListView.tsx`'s `resolveListMapConfig` now merges the view-level `map` block over the legacy `options.map` bag before `case 'map'` builds the `object-map` schema, and the same merged config also feeds the visualization-switcher's capability gate — so a view that binds its coordinates only in the spec's `map` block both renders on the map surface and is no longer filtered out of `allowedVisualizations`. Nothing about the schema shape or the authoring-time validation changed; only the runtime consequence of authoring the key does. + + Pinned by objectui `packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx` (the forward, against a spy) and `packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx` (the end-to-end read through a real `ObjectMap` — markers, titles, camera). Re-measured against objectui `origin/main@08ca73f8` (squash commit `e2e8e68` for #5908 confirmed an ancestor). +- 33c5fd3: liveness ledger: repoint the `action` `type` / `body` / `method` anchors at their real consumers + + All three cited `packages/runtime/src/http-dispatcher.ts`, and none of them is read + there. The actions domain was extracted out of that file — it now only delegates + (`handleActions` → `handleActionsRequest` at :1969-1970) — and the reads live in + `action-execution.ts`, `domains/actions.ts`, `sandbox/body-runner.ts` and, for the + client-dispatched `method`, in the renderer repo. + + This is the residue of the same extraction that rotted `action.target` and + `action.requiredPermissions`, but it survived the sweep that repaired those, and the + reason is the interesting part. The key-mention check added with that sweep asks + whether the cited file names the key at all — which caught `target` and + `requiredPermissions` because `http-dispatcher.ts` contains **0** occurrences of + either. It cannot catch these three, because they are common English and HTTP words + that the file is full of for unrelated reasons: + + - `type` — 9 occurrences, four of them the TypeScript `import type` keyword, the rest + other domains' data (`error.type`, `details.type`, a field-type→JSON-Schema mapper). + - `body` — 68 occurrences: the inbound HTTP request body threaded through every + domain delegate, plus the file's own "body extracted to ./domains/…" comments, + where `body` means a *function* body. + - `method` — 41 occurrences: the inbound HTTP verb (15 `method: string` parameter + declarations, route matching such as `method === 'GET'`) and the ordinary + object-oriented sense in prose. + + So the word-bounded check anchors on the coincidence and passes. That is the designed, + honest limit of the signal rather than a defect in it — the census that shipped it said + so — and it means this class is invisible to tooling and only a hand call-graph read + can settle it. Each repointed entry now records which spelling misled the gate, so the + next reader does not have to re-derive it. + + `method` additionally CHANGES REALM, joining its siblings `bodyShape` and `bodyExtra`: + `type: 'api'` actions are client-dispatched by design, so the server never read the + verb. Its one in-repo appearance is a diagnostic that interpolates the verb into the + refusal explaining the server does *not* dispatch it — evidence of non-consumption, + and deliberately not cited as a consumer. + + No verdict was re-graded: all three were `live` and remain `live`, with the consumer + proven rather than asserted. Citation repair only. +- 20b0fdb: liveness gate: a citation must name the property it is evidence for + + Two checks already bounded a `live` entry's citation, and both bounded it from the + outside — the cited file must exist (#5623), and a cited line must be inside it + (#11210). Between them sat a gap neither could see: a consumer that moves *within* + the file it is cited to, or a citation written with no line at all, leaves the file + present and every named line in range. The pointer is wrong and the gate is green. + + Measured over the whole ledger before anything was switched on: 403 (entry, cited + local file) pairs, **11** where the cited file never mentions the property's own key, + and **7 of those 11 were real rot** — repaired here: + + - `permission.objects.allowExport` — `annotateEffectiveApiOperations` moved to + `current-user-endpoints.ts`; the same repos-internal movement that had already + rotted `permission.systemPermissions` and `permission.tabPermissions`. + - `object.tenancy.organizationField` — the resolver was promoted into + `@objectstack/metadata-core`; the cited `audit-writers.ts` says so itself, in the + re-export comment left behind. + - `action.target` / `action.requiredPermissions` — the actions domain was extracted + out of `http-dispatcher.ts`, which retains 0 occurrences of either key. + - `action.bodyShape` / `action.bodyExtra` — client-dispatched keys whose only + consumer has always been the renderer; the in-repo citation could not have been + right at any point. Now attributed to `objectui` with the commit pinned. + - `field.requiredWhen` — cited its *sibling* `record-validator.ts`, which enforces the + static `required` contract; the CEL predicate is evaluated one file over in + `rule-validator.ts`. Both files exist, so nothing could see it. + + The remaining 4 are the `camelCase` → `snake_case` convention this platform mandates + (Prime Directive #3): a property persisted as a column is read as `body_html`, + `managed_by`, never as the authoring key. Three are handled **structurally** — the + matcher folds the key across the naming convention rather than exempting them — and + the match is word-bounded so a prefix cannot satisfy the key (`required` is not + `requiredWhen`, which is precisely how that rot stayed hidden). The one residual is a + compound *child*-key remap (`fromOverride.address` → `from_address`) that no fold of + the parent key reaches, and it is a single explicit row in the shrink-only + `scripts/liveness/key-mention.baseline.json`, which fails in **both** directions: a + row whose pair later anchors must be deleted. + + So the check ships red-capable at zero unexplained hits, which is the whole reason the + census came first — `evidence.mts`'s header records what the alternative costs, when + 48 of 227 entries were flagged, every one was a false positive, and the single genuine + rot inside that list sat unread. + + The check asks `evidence` only, never `producer`: a producer cites *who supplies a + second input* (#4837), which is by definition a call site and need not name the key at + all. +- 905019b: liveness gate: bound an evidence citation's LINE, not just its file + + `check:liveness` resolved a `path/to/file.ts:NNN` pointer with `existsSync` on the + path alone — the parser did not even retain the `:NNN` — so a consumer that moved + out of a file which still exists kept a passing pointer, was counted under the word + "resolved", and left its ledger entry reading as freshly verified. A citation that is + dead but precise-looking is worse than a missing one: it survives review, and the next + agent re-verifying the entry follows it, finds nothing, and rebuilds the call graph + from scratch. + + Citations are now bounded by the cited file's length, for `evidence` and `producer` + alike (they already share one resolver). A range `:12-34` is bounded by its END. Every + citation in a `+`-joined multi-consumer entry is bounded, not just the first. + Cross-repo attributions (`objectui: …`, `cloud: …`) are still counted and never + resolved. The run prints how many citations it checked beside how many are in range, + so a parser that degraded to extracting nothing cannot read as a pass. + + Two shipped instances, both repaired here and both real: + `permission.tabPermissions` cited `hono-plugin.ts:1200` in a 717-line file that no + longer mentions the property (all three of its pointers were dead — one past EOF, two + within bounds), and `mapping.fieldMapping` cited a range ending three lines past the + end of `import-mapping.ts`. +- 93ea19b: chore(spec): re-anchor the `action` and `object` liveness ledgers to consuming symbols (#13003) + + Adoption batch 1 of the symbol-anchor citation grammar landed by #12516. The + `liveness/` ledgers ship inside this package's npm tarball (they are named in + `files`), so this is a published-data change even though no runtime behaviour + moves and no schema key changes. + + Twenty `path:NNN` evidence citations across `liveness/action.json` and + `liveness/object.json` are now written `path#symbol`, each re-closed by reading + the code on the current tree rather than by shifting a line number. A symbol + moves with its consumer, so the pointer survives the in-file drift that rots a + line, and goes red when the consumer is renamed or deleted — a direction a + stale line can never produce. + + What the re-closure found, which is the reason the migration is not mechanical: + fourteen of the fifteen `object.json` citations were pointing at the wrong place + already, every one of them IN RANGE and so invisible to all three existing + checks. They had drifted onto a docblock about aggregate-function lowering, a + job-scheduling block, a neighbouring `const`, and — for `object.enable.clone` — + a sort-node normalizer roughly 6,700 lines from its actual reader. Three had + additionally moved package: the `ownership`, `managedBy` and `tenancy.enabled` + readers now live in `@objectstack/spec` itself, and one of those citations + carried a parenthetical (`applySystemFields reads schema.ownership`) that the + re-read falsified outright — that function's nine `ownership` occurrences are + all comments about a decision it delegates. +- 9ee2dcf: chore(spec): re-anchor the `manifest`, `datasource`, `permission`, `dataset` and `webhook` liveness ledgers to consuming symbols (#13003) + + Adoption batch 2 of the symbol-anchor citation grammar landed by #12516, following + batch 1's `action` / `object` pass. The `liveness/` ledgers ship inside this + package's npm tarball (they are named in `files`), so this is a published-data + change even though no runtime behaviour moves and no schema key changes. + + One hundred and five `path:NNN` evidence citations across + `liveness/manifest.json` (34), `datasource.json` (28), `permission.json` (25) and + `dataset.json` (18) are now written `path#symbol`, each re-closed by reading the + code on the current tree rather than by shifting a line number. A symbol moves + with its consumer, so the pointer survives the in-file drift that rots a line, + and goes red when the consumer is renamed or deleted — a direction a stale line + can never produce. Every entry touched is stamped `verifiedAt: 2026-08-28`, which + dates `datasource.json`, `dataset.json` and `webhook.json` for the first time. + + What the re-closure found, which is again the reason the migration is not + mechanical: 88 of the 105 citations were pointing at the wrong place already, + every one of them IN RANGE and so invisible to all three existing checks. They + had drifted onto docblocks, blank lines, a `variant: 'secondary'` UI action, a + `return m.aggregate` belonging to a different key, and — for `datasource.ssl` — + a contiguous run of five wrong pointers into a docblock about an unrelated + environment variable. Two prose claims were falsified outright and are withdrawn: + `external.validation`'s note credited `checkOnBoot` with gating the boot sweep + (nothing reads that key anywhere; the sweep is unconditional), and + `objects.viewAllRecords` cited a reader named `hasViewAllData` that no longer + exists in the tree. + + `webhook.json` is the batch's separate case. All eleven of its `live` entries + cited bare FILENAMES with line numbers (`auto-enqueuer.ts:266`), which the + evidence scanner cannot parse as citations at all — so that ledger contributed + zero resolvable paths and was never actually asked about by any check. Rewriting + them as repo-rooted anchors made them askable, and the key-mention check + immediately found a real answer: the dispatcher reads the remapped `active` + column and never the authored `isActive`, so that call site now lives in the + entry's note rather than in its evidence. + + `tool.json` is deliberately unchanged: every one of its citations points into + `packages/services/service-ai`, the closed cloud runtime, which is absent from + this checkout — there is no code here to re-close it against. +- 8cb96ec: chore(spec): re-anchor the `email_template` / `api` / `doc` / `book` / `query` / `job` liveness ledgers to consuming symbols (#13003) + + Adoption batch 3 of the symbol-anchor citation grammar landed by #12516, + file-disjoint from batches 1 and 2. The `liveness/` ledgers ship inside this + package's npm tarball (they are named in `files`), so this is a published-data + change even though no runtime behaviour moves and no schema key changes. + + Ninety-five `path:NNN` evidence citations across `liveness/email_template.json`, + `api.json`, `doc.json`, `book.json`, `query.json` and `job.json` are now written + `path#symbol`, together with every path-only pointer in the same entries, each + re-closed by reading the code on the current tree rather than by shifting a line + number. A symbol moves with its consumer, so the pointer survives the in-file + drift that rots a line, and goes red when the consumer is renamed or deleted — a + direction a stale line can never produce. + + What the re-closure found. Seventy-nine of the ninety-five citations were already + pointing at the wrong place, every one of them IN RANGE and so invisible to the + existence check, the line bound and the key-mention check alike. Four whole files + were 100% rotted — `doc.json` 15 of 15, `book.json` 13 of 13, `query.json` 12 of + 12, `job.json` 11 of 11 — and the sixteen accurate citations that survive are + concentrated in two places: twelve of the thirteen row-mapping pointers in + `email_template.json`, and four pointers into short, stable policy helpers in + `api.json`. In `doc.json`, its `book.zod.ts` pointers had come to rest inside + the `ResolverDoc` / `ResolvedEntry` INTERFACES — type declarations of the very + fields whose consumers they claimed to cite, which is what a `dead` key has too — + while the whole doc-serving block of `rest-server.ts` moved roughly 1,600 lines + out from under the rest. `email_template.json`'s nine `email-service.ts` pointers + rotted together when #9225 split the resolver out of `sendTemplate` into + `resolveAndRenderTemplate`, moving the reads from the 400s to the 1,170-1,310 + band; one of them landed 1,150 lines away on a docblock about the `sys_email` + outbox id contract. Two security-shaped keys had their only pointer land on a + DIFFERENT key's enforcement: `api.authRequired` cited the rate limiter's 429 + body, and `email_template.variables` cited the exact line its sibling `isSystem` + cites for itself. + + Two pointers were falsified in PROSE as well as position. `api.cacheTtl` cited a + line that was still accurate and named `cacheControlHeader`, which is not a + symbol anywhere in `packages/**` (the function is `computeCacheControl`); + `query.where` named `applyFilters`, which is real and sits roughly 11,250 lines + from the position cited beside it. + + Two entries were not checkable at all rather than merely stale. + `query.expand`'s pointer was written `engine.ts:2519+`, and the trailing `+` + stops the token matching the scanner's path pattern — so it degraded silently to + prose and was never resolved, bounded or key-checked, while reading like the most + precise citation in the file. `query.aggregations.filter`, the youngest entry in + that ledger, wrote five of its eight consumers as bare package+filename prose + (`driver-sql sql-driver.ts`), which the same pattern cannot parse. Both are now + repo-rooted anchors — the `webhook.json` class from batch 2, reproduced here once + by a single character and once by a missing prefix. + + `job.json` is the file that argues the case most directly: its 2026-08-02 note + records that the seeded lines had already drifted ~25 lines and were restamped + with fresh numbers. Twenty-six days later every one of those fresh numbers had + drifted again, onto `} else {`, a bare `try {`, a bare `}` and a line about + registering actions. Restamping a line is the same claim with a newer date. + + Nothing is re-classified here. Entries whose evidence lives only in `objectui` at + a pinned commit this checkout cannot reproduce — `book.description` / `.slug` / + `.icon` / `.order` and `job.label` / `.description` — are left byte-for-byte + untouched and are NOT re-stamped, on the `tool.json` precedent: dating a call + graph nobody re-closed is the false confidence this ledger exists to prevent. +- 8f10a79: chore(spec): re-anchor the closing twelve liveness ledgers to consuming symbols (#13003) + + Adoption batch 4 — the closing batch — of the symbol-anchor citation grammar + landed by #12516. The `liveness/` ledgers ship inside this package's npm tarball + (they are named in `files`), so this is a published-data change even though no + runtime behaviour moves and no schema key changes. + + Eighty of the eighty-two remaining `path:NNN` citations — across + `liveness/app.json`, `validation.json`, `translation.json`, `field.json`, + `hook.json`, `mapping.json`, `capability.json`, `seed.json`, `view.json`, + `dashboard.json`, `flow.json` and `qa.json` — are now written `path#symbol`, + each re-closed by reading the code on the current tree rather than by shifting a + line number. Every path-only pointer in the same entries is anchored with them. + The gate's own line-citation counter goes 82 to 2 and its symbol-anchor counter + 383 to 499. + + Sixty-seven of the eighty were already wrong, every one IN RANGE and therefore + invisible to the existence check, the line bound and the key-mention check alike. + Six files were 100% rotted (validation 10/10, seed 6/6, view 5/5, dashboard 4/4, + flow 4/4, qa 4/4). The dominant shape is BLOCK drift: nine of `validation.json`'s + ten pointers had come to rest inside one 70-line ADR-0124 docblock roughly 1,200 + lines above `evaluateValidationRules`, and `app.json`'s `name` / `label` / + `description` had settled on three consecutive lines of one unrelated action + resolver. `flow.status` is the sharpest single case — its pointer landed on + `flowLedgerDisabled`, the map ADJACENT to the one this key writes, after #10243 + split the two apart. + + Two silent classes are closed with them. Seventeen positions across four files + were written as bare `:NNN` suffixes with no path in front of them, which the + scanner never matched as citations at all, so nothing ever resolved, bounded or + key-checked them; `translation.objects` alone carried five. Three more entries + (`hook.retryPolicy` / `timeout` / `onError`) cited a path with no line, which a + line bound cannot falsify by construction. All twenty are repo-rooted anchors now. + + Three citations were falsified in PROSE as well as position and are corrected + with their lines: `view.object` credited `getViewsByObject()` to + `packages/objectql/src/engine.ts` (it is in `packages/metadata`), + `field.requiredWhen` named `fieldRequiresParentRoot` (no such symbol; it is + `hasParentScopedRequiredWhen`), and `translation.metadataForms` named + `translateMetaTypes` (it is `translateMetaTypesResponse`). + + Nothing is re-classified. Two entries are deliberately left byte-for-byte + untouched and un-restamped because their `live` verdict — not their citation — + is what is falsified: `action.execute` (reported as #13036) and + `field.conditionalRequired` (reported as #13043), both `retiredKey` tombstones + since protocol 17 whose cited `.transform` lowering no longer exists. Those are + ADR-0049 re-classifications with their own card shape. +- 6269a55: Gate the shipped `llms.txt` against the real package, and repair the claims that had rotted. + + `packages/spec/llms.txt` ships in the npm tarball as context for AI consumers and is + hand-kept with no generator, so nothing ever re-derived what it asserts. It had drifted + badly: eleven advertised symbols existed in no entry point (`IUIService` — removed in + v11 — plus `ThemeSchema`, `IdentitySchema`, `PolicySchema`, `ContractSchema`, + `EndpointSchema`, `RAGPipelineSchema`, `MCPSchema`, `FilterSchema`, `AnalyticsSchema`, + `FormSchema`), two advertised packages did not exist (`@objectstack/nextjs`, + `@objectstack/nestjs`), the schema-inventory heading disagreed with the sum of its own + table (171 vs 170) and with the tree (207), and the package heading claimed 19 against a + real 68. An agent reading the file wrote imports that do not resolve. + + New gate `check:llms-txt` re-derives every checkable claim on every PR: advertised + symbols against the checked-in `api-surface/` shards, `@objectstack/spec/x` subpaths + against the manifest `exports`, the per-domain schema counts against + `src//**/*.zod.ts`, and the package table against the workspace. Symbol claims + are resolved at the strictness their position earns — namespace bullets and fenced + imports name an entry point and must resolve from it, while the architecture overview + resolves against the union. Prose, code-fence bodies and `N+` lower-bound figures are + out of population and the script header says why. + + There is deliberately no `gen:llms-txt`: the numbers are not the claim, the prose beside + them is, and restamping a count without re-reading its row would turn a loud staleness + into a silent lie. +- ce7e497: The `namespace` rejection messages now cite the decision record that actually exists: ADR-0129 D3 (the enforced object-naming contract — `name` is the canonical id, module prefix embedded literally, no separate namespace declaration). The object tombstone's citation, removed when its previous spelling pointed at a decision letter no ADR declared, comes back pointing at the real record; the translation-contract rejection re-points the same way. The retirement itself is unchanged and still enforced. +- 42a117b: Document why `NoSQLIndexSchema.unique` stays a bare boolean instead of the ADR-0120 unique-scope vocabulary carried by `FieldSchema.unique` and `IndexSchema.unique`: the schema is a raw NoSQL driver-configuration descriptor below the tenancy seam — nothing materializes indexes from it, and the one NoSQL driver that creates indexes (driver-mongodb) consumes the object-level `indexes[]` surface (which already carries the vocabulary) and is explicitly single-tenant (#3724) — so a scope word here would be declarable-but-inert vocabulary (ADR-0078). The `describe()` and docblock now state the deliberate omission and the condition under which `UniqueScopeSchema` should be adopted, so the asymmetry with the other two `unique` surfaces is not mistaken for drift (#11215). +- 4297fe7: `NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale + + The `notify` node's localizable path (`template` → a `sys_email_template` bundle) + was documented in `packages/spec/src/automation/io-node-config.zod.ts` as + resolving `(name, recipient locale)` **per recipient** at delivery time, and the + `template` `.describe()` added that it "renders subject/body per recipient". + Read plainly — and it is the text a consuming app's author reads — that says the + recipient's own language selects the template row. + + It does not, and deliberately does not. The delivery path + (`service-messaging/src/email-channel.ts`) has said so honestly at its own + `getDefaultTemplateLocale` all along: the platform has no per-user locale + (`sys_user` carries no locale column), and request-scoped locale + (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async + delivery time, so "recipient locale" resolves to the **deployment default**, + `II18nService.getDefaultLocale()` — the same ruled source the auth emails use. + The one lever is `payload.locale`, and that is interpolated **once, before + fan-out**, so it is a single value for the whole notification at all three + `channel.send` call sites (`fanOut`, the outbox single-delivery path, and + `processDigestGroup`). + + The gap mattered because the wording licensed exactly one conclusion — "convert + the nodes and non-English users get non-English notifications" — which is false, + and acting on it is a **net regression**: `TEMPLATE_*` failures classify + `permanent` and dead-letter, and the inbox channel starts requiring an email + service with `renderTemplate()` where inline text needed none. So the drift was + not a cosmetic imprecision; it was an instruction to make a change that loses + deliveries. + + Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side — + a per-user locale is deferred until measured pull — so the prose is the side that + moves. All five "recipient locale" sites in the file now name the resolved value: + the schema doc block, the `template` field's JSDoc and `.describe()`, and both + `superRefine` refusal messages. Each says the locale is `payload.locale` if the + producer set one, else the deployment default, and that it is **one value per + notification, not one per recipient**, with the 2026-08-13 deferral dated in + place so the limitation reads as a decision with provenance rather than a + permanent property of the design — a per-user locale layers in as an override at + that same seam when it lands. + + Text only. No schema accepts or refuses anything it did not before, no delivery + behaviour moves, and no wire value changes — `packages/spec` publishes + `src/**/*.zod.ts` and the generated reference page, so the corrected wording + ships to consumers reading either. The pins in + `io-node-config.test.ts` that asserted the old `/recipient locale/` string now + assert the qualification itself, and refuse a bare "recipient locale", so a + future edit cannot quietly restore the promise. +- 0c77ea4: docs(spec): wrong-layer guidance for `group` / `hideFields` / `rowColor` on the object `userActions` block (#11459) + + `data/object.zod.ts`'s object-level `userActions` block already curated + `sort` / `search` / `filter` / `editInline` with a pointer at the VIEW + `userActions` block they actually belong to. `group` / `hideFields` / + `rowColor` — adopted onto the view's vocabulary at #11195 — shared the same + wrong-layer trap but got only the generic unknown-key rejection with an + edit-distance suggestion, which has nothing useful to offer over the object + block's `create`/`import`/`edit`/`delete`/`exportCsv` shape. + + **Nothing changes about what parses.** All three keys were rejected on the + object block before this change and are rejected after it; only the message + gains the same curated pointer the other four wrong-layer keys already carry. +- be93457: fix(spec): punctuate the `zh-CN` Operation Message Catalog with the full-width comma (#12717) + + Every `zh-CN` entry in `BUILTIN_OPERATION_MESSAGES` separated its clauses with + U+002C (the ASCII comma) while ending the same sentence with U+3002 (the + full-width period) — two punctuation systems inside one sentence. In Chinese + typography the half-width comma reads as a lapse rather than as a style, and + these are strings a business user reads in a toast. + + The two surfaces genuinely sit together on one screen: an approval action's own + description renders full-width (`plugin-approvals`' + `zh-CN.objects.generated.ts`, U+FF0C / U+FF1F) while a refusal from the same + feature rendered ASCII. + + Twelve U+002C across the nine `zh-CN` entries — the five situation keys + (`permission_denied`, `record_access_denied`, `record_change_not_allowed`, + `record_write_denied`, `approval_recall_not_submitter`) and the four + `delete_restricted*` variants — become U+FF0C. Nothing else moves: the U+3002 + sentence finals were already correct, the two U+3001 (、) enumeration commas in + the `_required` variants are correct Chinese and stay, and the `en`, `ja-JP` and + `es-ES` rows are untouched. + + Text-face only — no key, placeholder, wire code, status or accept/reject + behaviour changes. A deployment that overrides `errors.` through a + `translation` is unaffected; only the built-in default moves. +- 5691b07: Refresh the `tenancy.organizationField` scope-pin annotation and its + `.describe()` in `packages/spec/src/data/object.zod.ts` — prose accuracy + only, no schema or behaviour change. + + The #8778 scope-pin annotation (landed by #10999) said consumer 1 (audit + stamping) was "as of this annotation, still the only one wired up" and that + consumers 2 and 3 (the approval-row writer and the automation-run recorder) + were "sanctioned but not yet implemented". #10101's PR #11311 (merged + 2026-08-23) landed both: `resolveRecordOrganizationField` was promoted to a + shared resolver in `@objectstack/metadata-core` (plugin-audit re-exports it + from its original path) and all three sanctioned platform-row writers now + call it. The annotation and the `.describe()` string are updated to name all + three live consumers; the pin's load-bearing property — "The ruling sanctions + exactly THREE consumers of this key, and no others", a fourth consumer needs + its own ruling — is unchanged and still stated verbatim. + + No accept/reject behaviour change, no schema shape change, no new keys. +- fb5fbb8: Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility. +- d7b3963: Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test. +- 2d2e6f0: fix(spec): `FormFieldPublicPickerSchema.object` now names `reference` — the key `FieldSchema` actually accepts — instead of the rejected `referenceTo` alias (#13138) + + Both sentences on the `object` key told authors the picker target resolves from + `referenceTo` on the parent object's field definition. `referenceTo` is not a + key `FieldSchema` accepts: it is a **rejected alias**, listed in the field + schema's alias map only so `strictUnknownKeyError` can offer a rename hint when + a parse fails on it. An author who followed the sentence and wrote `referenceTo` + on the parent object's field got their whole object metadata refused at parse — + a failure, not a degraded render. + + Measured against the built `packages/spec/dist/data/index.mjs` with a three-level + control (a two-level one cannot separate "rejected" from "not measured"): + + - `reference: 'sys_user'` (positive control) parses, `parsed.reference` is `'sys_user'` + - `zzz_not_a_key` (negative control) is refused `unrecognized_keys`, with no rename hint + - `referenceTo` is refused `unrecognized_keys`, **with** the hint ``Did you mean `referenceTo` → `reference`?`` + + The `.describe()` half is the load-bearing one: it is published, flowing into the + generated JSON Schema and `content/docs/references/ui/view.mdx`, so the wrong key + name reached authors who never open this file — and AI authors reading the + generated schema as ground truth. The reference docs are regenerated with the + repo's own `gen:docs` in the same change. + + Prose only: no schema shape, no accept/reject movement, no new or removed keys — + every previously-valid input still parses byte-identically. This takes no + position on whether the REST route's legacy-spelling fallback chain survives: + the sentence was wrong under either outcome, because a conformant authored field + carries `reference` in both worlds, and the route already reads `reference` first. +- b5a2398: Correct three stale `current_user` binding claims about a form FIELD `visibleWhen` + + A runtime form field's `visibleWhen` has resolved `current_user` — and the ADR-0068 D1 aliases `user` / `ctx.user` / `os.user` — since objectui#6010, but three texts shipped by these two packages still told authors the root was unbound there, and that per-option `visibleWhen` was "the only `*When` surface where it resolves". + + - `@objectstack/spec`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` now state the binding together with the two limits it does not remove — it is a rendering rule that nothing on the write path evaluates, so a role test written there protects no data; and the scope belongs to the host, so it is empty on the console's public standalone form route, where the predicate faults and visibility fails open. The generated `content/docs/references/ui/view.mdx` rows follow from the `describe()`. + - `@objectstack/lint`: the field-rule prescription no longer grounds "move it to the option's own `visibleWhen`" on exclusivity. It grounds it on enforcement — the rule validator evaluates a per-option predicate on every write — and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule, since moving one there would trade a loud lint error for a silent enforcement gap. + + No schema, validation or verdict change: the set of accepted metadata is byte-identical, and the rule still refuses a user root on an object field-level `*When`. +- 033a34c: **Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). + + The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). + + **What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. + + **Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. + + `@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. +- 146f448: fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588) + + `json-schema/objectstack.json` ships in the tarball (`json-schema` is in the + package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes + what the field means: "its `x-schema-count` field reports the total number of + definitions". It did not. The generator took the number from `count` — a + counter incremented once per emitted schema — while the bundle's `$defs` is + assembled from a map keyed by `/`. Every def key written more + than once therefore widened a gap nothing reconciled: the published bundle + declared **1596** definitions while carrying **1585**. + + `$defs` is now assembled before the envelope and the field is taken from its + size, so the artifact describes itself. The per-schema files on disk already + agreed with `$defs` (1585) — the same key collapses the file writes — so this + brings the one disagreeing number into line with both of the others, and the + docs sentence is true as written without changing it. + + **The collapsed emits are now named rather than implied.** The 11 def keys + written twice are all **benign self-aliases** — `export const X = XSchema` + spelled as `Object.assign(XSchema, …)`, one schema object reached by two export + names, so the second write cannot change what is published. Eight in `api` + (`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`, + `ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`, + `RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`, + `QueueConfig`, `Task`). No schema is being silently dropped: the existing + `findDefKeyCollisions` guard exits the build on any def key claimed by two + *different* schemas, so a build that produces a bundle at all has only exempt + ones — and `gen:schema` now prints that population instead of leaving it + visible only as a subtraction between two summary lines. + + No schema content changes; only the bundle's self-description and the + generator's console output. +- ca1965f: The select-option editability refusal no longer prints a bare internal issue id. + + Writing `disabled` / `readonly` / `editable` (or their `*When` forms) on a select + option is refused with a prescription explaining that editability is not a + per-option concern. That sentence carried a tracker id an author outside this + repository cannot resolve. It is gone; the citation keeps its durable half — + ADR-0049 and ADR-0068 are named in the same sentence — and the verdict, the + vocabulary and the rest of the wording are unchanged. +- 71f9cd1: docs(spec): strip the internal issue-id references that were projected into the published skill catalog + + The 2026-08-23 ruling stripped internal `#NNNN` citations from the published + skill corpus, but 14 of them were not authored in `skills/**` at all — they were + projected there from `.describe()` / TSDoc text in `packages/spec/src/**` by + `gen:skill-refs` and `gen:react-blocks`, so a hand-edit of the corpus could not + reach them and a regeneration would have put them straight back. + + Six source sites are rewritten to say the same thing without the citation, and + the artifacts are regenerated: the module summaries of `data/driver/common`, + `data/driver/config-registry`, `data/driver/turso`, `shared/retry-policy` and + `system/translation`, plus the `ListView.objectName` / `ListView.viewType` + deprecation notes and the `` summary in `ui/react-blocks`. The teaching in + each is kept, per the standing ruling of 2026-08-12, verbatim and untranslated: + 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. + + Customer-facing text changes in three places from the one source edit: the + published catalog (`skills/*/references/_index.md`, + `skills/objectstack-ui/references/react-blocks.md` and its sibling + `contracts/react-blocks.contract.json`), and the docs site + (`content/docs/references/data/driver-common.mdx`, `driver-turso.mdx`). No + schema shape, no `.describe()` used for validation, and no accept/reject + behaviour changes — the edits are comment and documentation text only. + + The doc-authoring gate's path exemption for the generated artifacts is removed + in the same change: it existed only because those files still carried projected + ids, and an exemption over a surface that no longer needs one is where the next + regeneration would smuggle one back in. +- ee17d86: `check:skill-examples`: make the marked-block EXTRACTION loop fence-aware, not just the orphan scan + + The orphan-marker scan learned to ignore an `os:check` marker shown as example text inside a + wrapping fence, so this gate's own convention could be documented in the very roots it governs. + The extraction loop was deliberately left out of that fix, and kept recognising a bare ` ```ts ` / + ` ```tsx ` / ` ```typescript ` fence-open line wherever it appeared, with no notion of sitting + inside another fence. A marker *alone* nested in an illustration was therefore handled correctly, + while a **fully worked** one — the marker AND a real ts fence, both written as example text inside + a wrapper — was extracted and handed to `tsc` as a genuine example: compiling by luck, or failing + the whole gate with a diagnostic pointing at documentation prose. + + Both loops now read one `fenceOwners()` walk. It records, per line, which top-level fence owns it, + so `owners[i] >= 0` answers the orphan scan's question ("is this marker inside a fence?") and + `owners[i] === i` answers extraction's ("does this ts fence open at top level?"). Because the + extraction loop's fence-open pattern is a strict subset of the CommonMark one the walk uses, a + genuine top-level block always owns itself and the new guard cannot suppress one. + + No occurrence in the corpus tripped this, so the counts are unchanged either side of the fix (257 + marked examples across 99 files, three surfaces). The self-test is where the defect is measurable: + a nested worked illustration whose payloads are deliberately uncompilable now extracts nothing, + while the identical payloads with the wrapper removed all extract — and the same pair is pinned + inside a JSDoc-gutter-wrapped docblock, where fence ownership has to be judged on the + gutter-stripped lines. +- cdbd920: fix(spec): keep machine constants off the skill-reference `Exports:` fallback + + When a `.zod.ts` has no module doc block, `build-skill-references.ts` falls back + to listing its exports. That line is TRUE — an accurate list of what the module + exports, which is why #12094 kept the fallback rather than refusing. What was + wrong is the RANKING: the list was whichever five exports happened to be + DECLARED FIRST, and the extraction had no notion of authorable surface, so any + `export const` qualified — including constants whose own names say they are not + for authoring. + + Three of the eleven modules that reach this fallback declare their machine + constants near the top, so three published rows headlined them: + + - `automation/approval.zod.ts` named `DEPRECATED_APPROVER_TYPES`, + `NON_AUTHORABLE_APPROVER_TYPES`, `ORG_MEMBERSHIP_LEVELS` and + `APPROVER_EXPRESSION_ROOTS` — four of its five slots + - `kernel/plugin.zod.ts` named `CORE_PLUGIN_TYPES`, `CONSUMER_INSTALLABLE_TYPES` + - `system/translation.zod.ts` named `LEGACY_OBJECT_FIRST_KEYS` + + `skills/**` is loaded whole into a customer agent's context window and its job + is to teach that agent what it may author, so a row headlining + `DEPRECATED_APPROVER_TYPES` and `NON_AUTHORABLE_APPROVER_TYPES` pointed an + authoring agent at exactly the vocabulary it must not use, with nothing on the + line marking them as such. No gate could see it: `check:skill-refs` compares the + artifact against the generator, and the generator ranked faithfully. + + `SCREAMING_SNAKE` exports are now dropped and source order is kept for what + remains, with the cap of five applied AFTER filtering so the authorable names + waiting behind the constants are promoted rather than the row merely shortened. + A module whose entire export surface is machine constants falls through to no + description at all rather than printing a bare `Exports:`. + + Sorting `*Schema` exports first was considered and NOT taken: on the very row + that motivated this it demotes `ApproverType` — the approver-type enum an author + actually writes — below four schema objects, which is worse by this surface's + own standard. The rule moves to `scripts/lib/export-list.ts` so it can be pinned + without running the generator, and `scripts/export-list.test.ts` enforces it + both as unit cases and as a corpus gate over the checked-in artifacts. +- 18c432e: fix(spec): publish each skill reference's own module doc block, not the first doc block in the file + + `build-skill-references.ts` derived every `_index.md` pointer description from + the first doc block anywhere in the source file. That is a rule about ORDERING, + not about descriptions: whichever declaration happened to sit nearest the top of + a `.zod.ts` donated its comment to a customer-facing page, and moving a helper + up a file silently rewrote published text. + + This is the defect the docs-site generator fixed by converging on + `findModuleDocBlock()` — the block must start at column 0, precede the first + declaration, and document no symbol. The skill-references generator was never + converted, so the two generators disagreed about the same sources. It now + imports the same selector rather than restating the rule, and `skills/**` is + loaded whole into customer agent context windows, so it was paying the higher + price for the same defect. + + Thirteen pointer rows across seven skills change. The live victim named on the + issue is `system/translation.zod.ts`, whose `objectstack-i18n` entry opened with + "Shared history sentence for every shape in this file." — the comment on a + private `TRANSLATION_HISTORY` string constant, meaningless to the reader and not + a description of the Translation protocol. Twelve rows in that class now fall + through to the existing `Exports: …` line, and one gains a real module + description (`shared/metadata-types.zod.ts`), which also brings it into + agreement with the docs page for the same file. + + Falling back rather than refusing is deliberate: an export list states a true + fact about the file, where the wrong block asserted a false one about its + subject. Whether a `.zod.ts` on this surface should be required to carry a + module doc block at all is a separate authoring question, left open here. +- 3c418c4: **Docs:** `skill.zod.ts`'s two `@example` blocks stop handing the author a retired key that throws on parse (#11026). + + Both TSDoc examples in `packages/spec/src/ai/skill.zod.ts` — the one over `SkillSchema` and the one over `defineSkill` — passed `triggerPhrases`, removed in `@objectstack/spec` 17.0.0 (#3896 audit close-out) and carried as a `retiredKey()` tombstone ~120 lines below the first of them. `defineSkill` calls `SkillSchema.parse()`, so both documented blocks **threw** when run: `invalid_type` at path `triggerPhrases`, expected `never`. A reader copying either block got a refusal on their first move. + + The blocks now demonstrate the tombstone's own prescription instead of contradicting it. That prescription is a **split**, not a rename — routing intent belongs in `triggerConditions` (an AND of context field/operator/value), natural-language intent in `description` / `instructions`, the strings actually put in front of the model — and the `defineSkill` block shows both halves with each named. `tools` is required with no default, so both blocks carry it. Each block also opens with its own `import { defineSkill } from '@objectstack/spec';`, matching the convention the marked SDK examples in `packages/client-react/src` already use, so the block is self-contained as a consumer would resolve it. + + The tombstone and every line of its guidance prose are **unchanged**, verbatim — they were always correct, and they are what the repaired examples now agree with. + + Prose only: no schema shape, no `.describe()` text, no runtime behaviour, no authorable-surface movement, and `check:generated` moves nothing (the generated reference page `content/docs/references/ai/skill.mdx` renders the tombstoned row as `[REMOVED]` and carries neither example). It is graded rather than skipped because the text ships to consumers: the rewritten blocks are present in the built `dist/skill.zod-*.d.ts`, which is what an editor surfaces on hover, and `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts` so the docblock also travels in the npm tarball as source. +- b3ca463: docs(spec): qualify the broken-sweep predicate in `FlowRunSummary` / `ConnectorActionEffect` TSDoc (#12722) + + Text only — no behaviour, no schema, no accept-set change. These doc blocks ship + in `.d.ts`, so a consumer reading the type got the sentence #12685 measurably + disproved. + + `selected > 0 AND acted = 0 AND unmeasured = 0` was stated as *the* broken-sweep + signal / query / alert across three doc blocks in two files. It is the FIRST + FILTER, not a verdict: a healthy idempotent sweep — re-select the same records, + gate each one on "already handled" — satisfies it on every run while that work + stands, so "over N consecutive runs" does not separate it from a dead gate + either (consecutiveness filters flapping, which is a different failure). What + discriminates is the per-node fold: a healthy skip is accounted for by a read + the run performed (`runs > 0` and `selected > 0` in `nodes[]`), while a dead + gate skips just as often with nothing behind it (`runs: 0`, or `selected: 0`). + + The wording mirrors what #12721 landed on `sys_automation_run`'s field + descriptions and `content/docs/automation/flows.mdx`, so the schema doc and the + object description now say the same thing — the point of the card, since the + schema doc is what a platform author actually reads. A corrected sentence in one + surface and a wrong one in the other is the state most likely to produce the + failure #12685 names: an operator wires an alert to the documented predicate, it + fires during normal operation, and it gets muted — leaving a dead sweep + unmonitored while looking monitored. + + The `unmeasured` clause's own point is unchanged, and now explicit: a run with + uncountable effects has an INCOMPLETE `acted` count, not a zero one. +- 0d4a6a8: build(spec): bound the DTS pass's heap ceiling to something the build container actually has (#12677) + + The DTS pass ran under `NODE_OPTIONS="--max-old-space-size=12288"`. A heap + ceiling is a promise to V8 that the memory exists: below it, V8 defers major GCs + and lets the resident set grow. A ceiling **above** the container's memory does + not permit a bigger build — it converts a recoverable JS heap error into a + kernel SIGKILL, because the process meets the container limit long before V8 + considers the ceiling reached, and `exit 137` carries no diagnostic. + + That is what took the docs site down: every `objectstack.ai` production deploy + since 2026-08-25 died with `@objectstack/spec:build` exit 137. Vercel builds the + docs app with `turbo run build --filter=@objectstack/docs` inside one + fixed-memory container, and this package is that app's only workspace + dependency — so this pass met the container limit alone. + + The ceiling is now `6144`. Measured on this package's DTS pass inside a cgroup + capped at 8192 MB, as peak anonymous RSS of the whole process tree: + + | ceiling | result | peak RSS | wall | + |---|---|---|---| + | 12288 (before) | ok, ~0.9 GB spare | 7290 MB | 132s | + | 6144 (after) | ok, ~2.3 GB spare | 5794 MB | 134s | + | 5120 | ok | 5328 MB | 148s | + | 4096 | `ERR_WORKER_OUT_OF_MEMORY` | — | 113s | + + No output change: every completing ceiling emitted a byte-identical declaration + tree (122 files, compared as one sha256 over all of them). The number buys + headroom and costs GC time, not build time. + + Should the live type graph ever outgrow `6144`, the pass now fails **loud** + with `ERR_WORKER_OUT_OF_MEMORY` instead of being killed silently. Raising the + number past what the build container has would trade that diagnostic back for + `exit 137`. +- eeba2ef: docs(spec): `FlowFunctionEffectSchema`'s broken-sweep claim is a first FILTER, not the detector (#12685) + + `patch`, and not empty: these doc blocks ship in `.d.ts`, and this file's + `@module` block is emitted verbatim as the intro of + `content/docs/references/automation/flow-function.mdx` — so the sentence an + operator reads while wiring an alert is a published artifact of this package, + regenerated in this change. No schema, no accept set, no runtime behaviour + moves. + + ## The wrong claim + + `packages/spec/src/automation/flow-function.zod.ts` stated + `selected > 0 AND acted = 0 AND unmeasured = 0` as *the* broken-sweep query, + unqualified — the third `packages/spec` surface carrying it, outside the fence + of the change that corrected `automation/execution.zod.ts` and + `integration/connector.zod.ts`. #12685 measured the A/B on one graph pair + through the real engine (pinned in `run-summary.test.ts`): a healthy idempotent + sweep — re-select the same records, gate each one on "already handled" — and a + dead gate BOTH report `selected > 0, acted 0, unmeasured 0`. "Over N + consecutive runs" does not rescue it: the healthy steady state is persistent + for as long as the outstanding work stands, so it trips on every run; + consecutiveness filters flapping, which is a different failure. + + The failure mode is silent. An operator who wires an alert to the documented + predicate watches it fire during normal operation and mutes it, leaving a dead + sweep unmonitored *while looking monitored*. + + ## What the prose says now + + The predicate is stated as the FIRST FILTER and the per-node fold + (`FlowRunSummary.nodes[]` / `gates[]`) is named as the discriminator — the same + shape `sys_automation_run`'s field descriptions, `content/docs/automation/ + flows.mdx`, `automation/execution.zod.ts` and `integration/connector.zod.ts` + now agree on. Each clause keeps its own true point: a declared-`writes` + function makes `acted` INCOMPLETE rather than zero, which is why + `unmeasuredEffect` keeps the first filter off the flows that call one, and an + under-reported `selected` can still only make the filter quieter, never wrong. + + Five mentions in the file were triaged individually; the one that reads + "an under-report reads exactly like the broken sweep #4354 exists to detect" + was left standing, because it claims nothing about the three-clause predicate + and stays true. +- 424f73c: Route the stored-envelope refusal to a command that exists — `os package publish`, not the retired `objectstack publish` (#12223) + + An author who hand-writes one of the seven `STORED_ENVELOPE_KEYS` onto an `api` + declaration is refused, and the refusal tells them where publication state actually + comes from. It named a command that resolves to nothing: + + ```text + before: Remove it — publication state is managed by `objectstack publish`, not authored. + after: Remove it — publication state is managed by `os package publish`, not authored. + ``` + + `os publish` was the legacy direct-to-environment command, retired with the path that + wrote `sys_environment_revision`. Re-measured on this tree against the **built oclif + `Config`** rather than against docs — loading the CLI's plugin and reading the command + table oclif derives from `dist/commands/**`: **61** ids, of which the only two containing + `publish` are `package publish` and `plugin publish`. There is no bare `publish` id and no + `publish` topic, so the old spelling exits as an unknown command. The message's own + neighbouring sentence already names `publishPackage` as the writer, and + `packages/cli/src/commands/package/publish.ts` is the command that runs it. + + This is the shape #12177 deliberately left alone elsewhere inverted: those sentences are + *about* the removal and are correct as history, while this one is **present tense and + prescriptive** — text an AI author obeys at the moment its write is refused. + + Text only. No accept/reject behaviour changes: the same seven keys are refused on the same + declarations, with the same `unrecognized_keys` upgrade path; only the sentence an author + reads is corrected. The same stale spelling is fixed in the `publisher` doc comment of + `packages/spec/src/cloud/package.zod.ts`, which ships to consumers in the package's type + declarations. +- a8d6b1d: Strip internal tracker ids from the refusal messages an author actually reads (#12124) + + Fifteen zod refusal messages across nine `packages/spec/src` files ended a sentence with + an internal issue id. Those strings are printed **at** the author, verbatim, the moment + their metadata is refused — by `os validate`, by a publish gate, by a parse — and the + reader has no tracker to open. A `#NNNN` there is a citation-shaped token that resolves to + nothing, in the one place the sentence most needs to be actionable. + + ```text + before: A field condition's keys are field names, never $-prefixed operators (#7711). + after: A field condition's keys are field names, never $-prefixed operators. + + before: ... refused at authoring time because the query path refuses it too + (400 INVALID_FILTER, #5869). + after: ... refused at authoring time because the query path refuses it too + (400 INVALID_FILTER). + ``` + + Where a customer-resolvable anchor already carried the meaning it was kept and the id + dropped beside it: the second example above keeps `400 INVALID_FILTER`, which is the token + an author can actually match their query-path error against. Where the reference is + load-bearing for an *internal* reader only, it moved to an adjacent `//` comment (four + sites: the endpoint publish gate's two `#5040` section pointers, the summary-field rule's + founding incident, and the interim renderer precedence behind the doubled-redirect + refusal). Elsewhere it is simply gone — git history keeps the anchor. + + Text only. **No accept/reject behaviour changes**: the same inputs are refused on the same + schemas with the same issue `code`, `path` and error shape; only the sentence changes. + Test twins that pinned the old wording now pin the new text plus a negative assertion that + the message carries no issue id at all. + + The convention is held mechanically from here — `check:doc-authoring` gained a third rule + that parses `packages/spec/src` and reds on an id in any refusal-message string. It parses + rather than scanning lines because refusal prose here is written as multi-line string + concatenation: a single-line `message:.*#[0-9]{3,5}` grep sees 1 of the 16 literals. +- e4a7695: docs(spec): stop recommending `contributes.routes` for code-handler endpoints — redirect the author-facing materials to the imperative `http.server` mount (#10726) + + `contributes.routes` (`packages/spec/src/kernel/manifest.zod.ts`) has **zero readers** + monorepo-wide: the only non-test read of `manifest.contributes` anywhere reads `kinds`, not + `routes` (`packages/objectql/src/engine.ts:4499`). An author following the shipped guidance + writes a `contributes.routes` entry, gets a clean parse, and serves nothing — ADR-0049's + silent no-op with a published recommendation attached. + + Per the maintainer ruling (2026-08-22, Option B), the four author-facing materials that + recommended the key are corrected **now**, ahead of and independent of the key's removal: + + - `skills/objectstack-api/SKILL.md` — the `apis:` decision table's second row now names the + imperative `http.server` mount and states explicitly that `contributes.routes` parses and + serves nothing. + - `packages/spec/src/api/dispatcher.zod.ts` — the HttpDispatcher protocol doc no longer + claims it "supports dynamic route registration from plugins via contributes.routes". + - `docs/adr/0088-metadata-kind-admission-and-retirement.md` — the `router` retirement row no + longer credits `contributes.routes` as a delivered form. The `router` KIND's retirement is + unaffected: its delivered forms are the imperative mount and, since #5040, declarative + `apis:`. + - `packages/spec/src/ui/app.zod.ts` — the `App.apis` removal message no longer sends + migrators to `contributes.routes`. + + The replacement recommendation was verified live on `main` before it was written, so this is + not a redirect to a second dead form: `http.server` is registered by + `packages/plugins/plugin-hono-server/src/hono-plugin.ts:271` (`providesServices` at :228) and + mounted by real in-tree consumers — `examples/app-showcase/src/system/server/recalc-endpoint.ts` + resolves it on `kernel:ready` and mounts `POST /api/v1/showcase/recalc`, and + `plugin-approvals`, `plugin-sharing`, `cloud-connection` and the CLI's `serve` all resolve the + same service. + + Schema-only change to prose: no key is added, removed or re-typed here. The + `contributes.routes` tombstone itself is #10724's, which is blocked on the `cloud` census + (#10812) — this changeset carries only the doc corrections the ruling ordered not to wait. + The two `content/docs/references/**` pages are the regenerated projection of the two `.zod.ts` + edits (`pnpm --filter @objectstack/spec gen:docs`), not hand edits. +- 14cfc00: Type-check the TSDoc code examples in `packages/spec/src` — the ADR-0033 authoring channel + + `check:skill-examples` now scans a third surface: `packages/spec/src/**` TSDoc blocks, + alongside `skills/` + `content/docs` and the client SDK sources. A schema's `@example` + is what an AI author copies, and it sits inches from the tombstone written for that same + reader — but nothing compiled it, so an example could name a retired key, a renamed + export or a tightened union and stay green indefinitely. (`check:doc-formula-expressions` + walks the same blocks but judges *formula expressions*, never TypeScript.) + + This is a new `SURFACES` entry, not a second extractor: the existing marker/tsc pipeline + was already surface-parameterised. Compilation stays **opt-in** via the `os:check` + marker, which matters more on this root than anywhere else — of its 146 fenced ts blocks, + 128 carry no imports of their own and three more are ellipsis-placeholder prose + (`defineStack({ ... })`) that is correct as documentation and can never compile. Six + self-contained blocks are marked and now compile against the built declarations. + + The marker is an inert HTML comment, as on the other surfaces, and is stripped from + generated reference pages rather than published to them. +- 750fff5: The `namespace` tombstone's rejection message no longer cites "ADR-0006 D4" — a decision letter no version of ADR-0006 declares. The retirement itself is unchanged and still enforced; the message keeps the fix (`name: "sys_user"`) and the #4001 parse-path history. The letter citation comes back when the retirement decision is actually written into an ADR. +- a5ef1d8: `isCompatible` now reports a TEXT column as exactly compatible with `signature` and `qrcode` fields (#12117). The SQL ⇆ field-type matrix in `type-compat.ts` had drifted from the platform's own emitted physical shape: after #11875 (maintainer ruling 2026-08-25) `sql-driver.ts` emits both types in its TEXT family alongside `richtext` / `code`, and their stored value is a string — routinely a data-URI — per `STRING_VALUE_TYPES` and `valueSchemaFor`. The `text` row's exact set never listed them, so `IExternalDatasourceService.validateObject` diffing a table the driver itself had created reported a `type_mismatch` against the very field type that wrote the column, and `generateObjectDraft` would not offer either type for a TEXT column. + + `binary` is unchanged, and `signature` stays in its exact set. The matrix is many-to-many by design — each row answers "which field types can THIS column serve?" and `isCompatible` is keyed on the column, so `text` is already exact under `text` / `uuid` / `enum` and `tags` / `multiselect` under `json` / `array`; a type joining one row does not leave another. Measured across the monorepo when the `text` row moved: no path stores a `signature` field value as a binary payload (the driver's only `binary()` column emission is the remote-column introspection mapping, never a field-type mapping), so the `binary` entry is import-side reach into foreign schemas — the only kind of schema this matrix is asked about — on the same content-class reading that puts `file` and `image` on that row, neither of which round-trips as bytes either (both are `FILE_REFERENCE_TYPES`, stored as reference-id strings). +- 29d0676: `validateAll`/`validateDatasource` now read each datasource's live schema once per sweep instead of once per federated object: the sweep threads a per-call introspection memo through the validation body, so M objects on one datasource cost one remote introspection round-trip (a rejected read is shared the same way — one connection attempt, M failure rows). The memo lives and dies inside a single call, so a long-lived service never serves a stale schema to a later sweep, and direct `validateObject` calls still read live every time. The `IExternalDatasourceService.validateAll` docstring, which promised "parallelised per datasource" while the implementation parallelised per object, now states the actual behaviour. +- 6bd3231: test(spec,drivers): add the `VALUE_ROUNDTRIP` conformance case-set — "what you wrote is what you read back", enforced per driver per dialect (#12393) + + The driver-conformance census was green at 9 of 9 dialect-scored cells after + #12136 promoted `MATRIXED`, and **none of its nine case-sets was about value + storage**. All nine ask *which rows come back*; none asks *what is in them*. So + that green was not weak evidence about a round-trip defect — it was no evidence + at all, and it would have stayed green forever with the defect in place. That is + why this family kept arriving one card at a time: #12380 (SQLite's `Field.json` + codec was not injective), #11535 (a multi-value field read back as the string + `'["x","y"]'`), #11782 (MySQL answering `1`/`0` for a declared boolean), #10995 + (PG json values bound without `JSON.stringify`). + + `VALUE_ROUNDTRIP_CASES` closes it as a class rather than as a tenth instance. It + is 41 cases over five declared value classes — `json`, `multiple: true`, + `string`, `number`, `boolean` — and every value in it is one some driver was + **measured** to change, or a control that stayed faithful in the same + measurement. Assertions pin **type as well as value**: the before-state of every + card above was a wrong type carrying a right-looking value, which survives + `toEqual`-style coercion and every truthiness check. `VALUE_ROUNDTRIP_COLLISION_PAIRS` + adds the injectivity half a per-value check cannot see — a string and the native + value whose encoding it resembles must stay distinguishable. + + Enrolled through the census's existing machinery rather than as a bespoke suite, + which is the whole argument for this route: `CLASSIFIED` obliges the new fixture + to be named in `CASE_SETS`, `CONSUMED` obliges every driver to run it, and + `MATRIXED` obliges `driver-sql`'s cell to be answered on **every dialect it + speaks** rather than on SQLite alone — the coverage shape that let #12380 survive + in the first place. The census now reads **50 covered cells across 5 drivers × + 10 case-sets, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt**. + + **No shipped behaviour and no public surface changes.** This is `@objectstack/spec`'s + `data` export gaining one conformance fixture, six new test files, and one + `CASE_SETS` row in the census script. No Zod schema, no runtime, no driver + source, no API. Graded `patch` for that reason: the package's published surface + grows by a test fixture that only conformance suites consume, and nothing an + existing consumer resolves changes shape. + + The one non-test change is a **test-double fidelity fix** the new case-set + surfaced: `driver-turso`'s `makeLibsqlSqliteStub` did not model `@libsql/client`'s + client-side boolean → `1`/`0` conversion, so a declared `boolean` written through + the REMOTE transport could not be bound at all. Verified against the dependency's + own source rather than the transport's comment; the transport is correct and + unchanged. +- b799ac5: Every view-family conversion now reaches all three persisted `view` spellings. + + `ViewMetadataSchema` accepts three body shapes and all three land in `sys_metadata` rows — the `defineView` container (`list`/`listViews`/`form`/`formViews`), the standalone ViewItem record (`{ viewKind, config }`), and the flattened runtime overlay (a raw ListView/FormView config at the top level plus its `object` + `viewKind` binding). Every view-family conversion walked only the container keys, so for the other two spellings the whole chain replayed by `applyConversionsToStoredItem('view', row)` was a no-op: a row written under an older protocol kept its historical shape while the conversion layer reported it canonicalized, and the rehydration parse then refused exactly what had never been rewritten. + + A new shared walker (`mapViewPayloads` in `conversions/walk.ts`) discriminates the three spellings using `ViewMetadataSchema`'s own discriminators — `viewKind` plus a `config` object for a record, the container slots for a container, `viewKind` with those slots absent for a flattened overlay — and hands each conversion the list/form payload wherever it lives, labelled with its family. All five view-family conversions adopt it: `view-visibleOn-to-visibleWhen`, `view-inert-keys-removed`, `view-list-passthrough-keys-removed`, `view-export-options-pdf-removed` and `form-view-option-default-removed`. + + The family label is load-bearing rather than informational: these conversions are shape-scoped, and two of them strip a key that is inert on one family and live on the other (`aria` is retired on a form and live on a list, `data` the reverse), so a walk that could not tell the two apart would delete live keys. + + No authoring surface moves and no accept set changes — this is data-at-rest canonicalization catching up to shapes the schema already ruled on. Container behaviour, including every notice path, is unchanged. +- 644ad50: `visibleWhen`'s describe now states the roots it actually binds, split by what is contract and what is renderer behaviour. + + `PageComponentSchema.visibleWhen` named three roots (`record`, `current_user`, `page.`) while the shipping renderer binds nine. The describe now separates the two claims instead of widening one into the other: + + - **Contract-bound**: `record`, `current_user` — with ADR-0068's aliases `user` and `ctx.user`, one object under three spellings — and `page.`. These are transcribed from a ruling, not from the renderer: ADR-0068 D1 rules a predicate "evaluates identically in a formula, an RLS policy, and a client `visible` gate", and `EvalUser`'s docblock already states the same alias set for "client UI gates". The platform's own `sys_user` alert gate uses `ctx.user`, which the old describe implied was unavailable. + - **Renderer-provided, not guaranteed**: `app`, `features`, `os.user` and `data`. ADR-0068's Non-goals fence its ruling to the user object, so nothing rules these on this surface; they are recorded as measured behaviour rather than promised. + - **`data` is surface-dependent** and is now called out as such: the data-source **adapter** on a component node, the record **row** on a `page:tabs` item-level `visibleWhen`. + + The `page:tabs` item `visibleWhen` (`ComponentSchema`) carried the identical three-root sentence plus a "binds the same environment as page-component `visibleWhen`" claim that measurement disproves; it is corrected the same way and now names its two real divergences (row-bound `data`, bare-field spread). + + Describe/prose only — no accept/reject change, no shape change, no new keys. +- 0da7cd2: Strip internal issue-tracker ids from the customer-facing text in `packages/spec` + + Refusal prose, unknown-key guidance, tombstone prescriptions and `.describe()` + docs no longer carry `#NNNN` references. A customer reading a rejected-metadata + error — or the generated reference docs — has no access to this repo's tracker, + so an id there is a citation-shaped token that resolves to nothing in the one + place the sentence most needs to be actionable. + + **FROM → TO, at bucket level** (584 strings across 88 spec sources): + + | bucket | strings | FROM | TO | + |---|---:|---|---| + | `strictObject` guidance / history / aliases | 262 | ``Until #4001 closed this shape these were dropped silently`` | ``Until this shape was closed these were dropped silently`` | + | `retiredKey()` tombstones + `*_RETIRED_KEY_GUIDANCE` | 130 | ``removed in @objectstack/spec 17 (#3894) — use `skills`.`` | ``removed in @objectstack/spec 17 — use `skills`.`` | + | `.describe()` docs prose | 187 | ``Parsed but no runtime consumer yet (liveness #1878/#1893).`` | ``Parsed but no runtime consumer yet.`` | + | zod `message:` (hoisted spelling) | 5 | ``a pair that cannot work as written (#9041).`` | ``a pair that cannot work as written.`` | + + **Kept, deliberately:** ADR ids, protocol and package versions, error codes, and + the `os migrate meta --from ` migration commands. AGENTS.md requires a + tombstone prescription to carry a durable reference, and those are the forms a + customer can actually resolve — the issue id riding beside them was the + strippable half. + + No behaviour changes: no schema accepts or rejects anything it did not before, + and no key, default or error `code` moved. This is the wording of messages and + generated docs only. +- 28a5c3e: Reference docs: qualify a union variant's `### Nested Shape:` and `### Allowed Values:` headings by which variant they belong to. + + The `### Union Options` renderer calls the property-table renderer once per variant, and both halves of the `Schema.key` qualifier those headings carry are shared by every sibling variant of one schema. Two `ViewItem` variants each declaring a shape-opening `config` therefore emitted `### Nested Shape: \`ViewItem.config\`` twice — two identical anchors on one page, on the very heading whose qualifier exists to prevent that. Measured on the published tree: 12 excess occurrences, 10 distinct headings, 4 pages, all under schemas rendering `### Union Options`. + + The heading now names the variant with the accessor grammar the page already prints — `ViewItem[viewKind='list'].config`, or `[option 2]` where the union pins no distinct discriminant — reusing the same `variantSelector` that stamps the variant segment into a property accessor, so a page carries one variant notation rather than two. `### Allowed Values:` is covered in the same change (no page collides there today; the exposure is identical). Headings outside a union variant, and unions with a single heading-emitting arm, are byte-identical: regenerating the 214-page tree changes 39 heading lines and nothing else. + + This changes what the docs site publishes, hence a release-visible patch rather than a skipped changeset. + ## 17.2.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 29d964fde7..9c7eed15c1 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.2.0", + "version": "17.3.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 0d4fdca919..8778cc3c6e 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/trigger-api +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 01f5a0696a..96de46f7b9 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.2.0", + "version": "17.3.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 7e742d5a68..bc786b8866 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/plugin-trigger-record-change +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 3e7fc6669b..74ea8b3a37 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.2.0", + "version": "17.3.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 a7addcaf33..7122571e5c 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/plugin-trigger-schedule +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [4bd6faa] +- Updated dependencies [983edf1] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [7131f12] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index a91d47bc5e..ebfe7200d7 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.2.0", + "version": "17.3.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 224c7cbf8d..346f7a36fd 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,375 @@ # @objectstack/types +## 17.3.0 + +### Minor Changes + +- db8c288: fix(types): let `sendError`'s `extra` carry `declaredCode`, so a nested-envelope route can emit the ADR-0112 open channel (#11719) + + `ApiErrorSchema` has declared `declaredCode` since #9106 — the open, + author-authored channel that carries a metadata app's own `.code` verbatim when + the spelling is not a member of the closed `code` vocabulary. ADR-0112's + 2026-08-17 amendment rules that demote **platform-wide**, and #9232 extended it + to the flat `/data` door, which emits the pair today. + + The shared nested-envelope writer could not. `sendError`'s `extra` was typed + `Pick`, so + passing a demoted spelling was a **compile error** and every route answering the + nested envelope dropped it. Nothing invalid shipped — the closed `code` still + carried the member derived from the status — which is exactly what made the loss + silent and one-directional: the author's spelling gone, and a consumer told by + the ADR to read `declaredCode` finding nothing there. Declared-but-unemittable + is a `declared = enforced` gap, closed here at the one writer rather than per + module. + + Additive: `declaredCode` joins the `Pick`. No existing call site changes, no + wire byte moves for any body already being emitted, and the contract's accept + set is untouched — the schema has always permitted the field. + + ⛔ Presence still MEANS demotion, and the writer does not re-derive that. The + caller passes `demotedDeclaredCode(thrown)` (`@objectstack/types`), exactly as + the flat door's `thrownCodeFields` does; that helper answers `undefined` when + the producer's spelling is already the vocabulary member sitting in `code`, so a + registered refusal never carries two spellings of one fact. Vocabulary and + position stay two decisions (#9232). + + Pinned in `response-envelope.test.ts` by driving the real pipeline — a + sandbox-shaped throw carrying a tenant-authored `.code` through + `resolveThrownHttpError` and `demotedDeclaredCode` — and by parsing the emitted + body with the real `ApiErrorSchema`, asserting the field is still on it *after* + the parse. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, + so a `.success` assertion alone would have passed against a schema declaring + nothing. +- 0e5fe7f: fix(types): let `sendError`'s `extra` carry `userMessage`, so a nested-envelope route can emit the #9934 user-facing channel (#12404) + + `ApiErrorSchema` declares `userMessage` — the producer-side opt-in for "this + exact text is addressed to the END USER" (#9934; maintainer ruling 2026-08-19 on + objectui#5210, option 1), where **presence IS the marking** and a consumer that + sees the field renders it verbatim instead of substituting its generic string + (#3821 preserved by construction, for everything unmarked). + + Two of the three doors already emit it: the flat `/data` door through + `withDeclaredUserMessage` (`@objectstack/rest`) and the dispatcher door through + `thrown.userMessage` (`@objectstack/runtime`). The shared nested-envelope writer + could not — `sendError`'s `extra` was typed + `Pick`, + so passing the field was a **compile error** and every route answering the + nested envelope dropped it. Nothing invalid shipped, which is what made the loss + silent and one-directional: the author's deliberate, localized refusal text + gone, and a consumer told to read `userMessage` finding nothing there. + Declared-but-unemittable is a `declared = enforced` gap, closed here at the one + writer rather than per module. + + Additive: `userMessage` joins the `Pick`. No existing call site changes, no wire + byte moves for any body already being emitted, and the contract's accept set is + untouched — the schema has always declared the field. + + The channel is live on both ends, which is what makes this a repair rather than + a new declared-but-dead surface: a hook opts in at throw time (host-side, or a + metadata app's sandboxed body whose `e.userMessage` crosses the QuickJS boundary + through `SANDBOX_ERROR_PASSTHROUGH`), and `resolveThrownHttpError` already + carries it onto `ThrownHttpError` for every caller of the shared resolver. + + ⛔ Unlike `declaredCode`, this field hands the caller **no invariant to + re-derive**. `declaredCode`'s presence means demotion, so its caller passes + `demotedDeclaredCode(thrown)`; `userMessage`'s presence means only that the + producer opted in, which `declaredUserMessage` has already decided (a non-empty + string, or nothing). The caller passes `thrown.userMessage` straight through, + exactly as the dispatcher door does. That difference is why `extra` stays an + explicit `Pick` rather than being derived from `ApiError`'s optional fields: a + derivation would admit every future optional on the day it lands, with nobody + asked what obligation the channel hands the caller — and these two fields needed + opposite answers to exactly that question. + + Pinned in `response-envelope.test.ts` by driving the real pipeline — a hook + refusal shaped like the one `hook-refusal-user-facing-marking.dogfood.test.ts` + drives, through `resolveThrownHttpError` — and by parsing the emitted body with + the real `ApiErrorSchema`, asserting the field is still on it *after* the parse, + paired with a control showing an undeclared sibling being stripped from the same + body. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, so a + `.success` assertion alone would have passed against a schema declaring nothing. + A blank marking is pinned ABSENT: the writer never invents a marked message for + a producer that wrote none. +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + +- 4d5b4f8: feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (#12751) + + On a **walled** deployment (`OS_TENANCY_POSTURE` in the wall-enforcing + family), the account whose email equals the declared platform owner + (`OS_PLATFORM_OWNER_EMAIL`) is stamped `emailVerified` **at creation** when + it comes into existence through an **operator provisioning path** — extending + the #11343 dev-boot seeded-admin precedent to production walled boots + (maintainer ruling 2026-08-28, cloud#1677: 「运营方创建即视为已验证」; the + trust anchor is the operator's env-var declaration plus the + operator-executed creation, not a mailbox round-trip; SMTP stays required + only for inviting others). + + **Which creation paths qualify** (the [#11739] audience taxonomy, not a + second classification): + + - the **bootstrap carve-out** — the very first account on a fresh install + (zero human users), the one self-serve creation a walled boot admits; + - **admin create-user / bulk import** (`method: 'admin'`) — an act only an + authenticated admin session can perform; + - **SCIM** (`method: 'scim'`) — provisioning executed by the + operator-registered directory. + + **Never**: non-bootstrap self-registration (including an + invitation-admitted registration typing the owner address), provider-class + JIT (the IdP asserts its own `emailVerified` at insert), any non-owner + address, any unwalled posture, and a later email **update** to the owner + address (the stamp is staged at the admission gate and consumed once by the + `user.create` before-hook — a seam an update cannot traverse). Dev-boot + behaviour (#11343) is unchanged. + + The `WALLED_OWNER_NO_VERIFICATION_PATH` boot warning now probes the owner + account's state: a fresh walled boot with no transport and no federated + sign-in is **silent** (the operator's own first-account creation arrives + verified — the case this closes), while an owner account that already + exists **unverified**, a populated store whose bootstrap window is spent, + and an unanswerable probe keep warning. A settled deployment whose owner is + verified stops re-warning on every boot. + + `@objectstack/types` gains `isEmailVerifiedUserRow` — the [#11343] + fail-closed verified-representation allow-list, moved from + `plugin-security`'s private copy so the elevation gate and the boot + diagnostic read ONE resolution (`plugin-security` now consumes it; no + behaviour change there). + +### Patch Changes + +- 2cf5a96: An organization no longer stops accepting members at 100 — membership is not a + limited axis, and the ceiling nobody chose is now stated explicitly + + A customer adding users was refused with `Organization membership limit + reached`. Nothing in this codebase set that ceiling: better-auth's organization + plugin substitutes a vendor default of **100** for an absent `membershipLimit` + (`count >= (membershipLimit || 100)` in `routes/crud-members`), and + `auth-manager` passed `organizationLimit` — how many organizations one user may + CREATE — while never passing `membershipLimit`, which is a different question. + + The two read almost identically in a config block and mean nothing alike, which + is why the gap survived: the option that WAS set looked like the option that + was not. In the field the refusal is worse than merely wrong — it arrives while + an operator is looking at licences and seat counts, and reads as an entitlement + problem on an axis that carries no entitlement at all. Seats are metered on AI + usage; plain membership has never been billed. + + - `membershipLimit` is now passed explicitly, defaulting to unbounded. + - `OS_ORG_MEMBERSHIP_LIMIT` is the opt-in for a deployment that DOES want a + ceiling (a pilot, a trial tenant). Unusable values (empty, non-numeric, + zero, negative) read as unset rather than as a cap — a typo must not be the + thing that locks an organization, which is exactly the failure mode being + fixed. + - The decision lives in `resolveMembershipLimitOption()` rather than inside the + plugin-construction expression, so it is testable: the unset case, the + explicit ceiling, the unusable-value direction, and — deliberately — that the + chosen value clears the vendor's 100 by a wide margin. If a future + better-auth changes that default, the test says so instead of leaving an + unexplained constant behind. + + The unbounded value is `Number.MAX_SAFE_INTEGER`, not `Infinity`: the option is + compared numerically but also travels through option plumbing that may assume a + finite value, and nine quadrillion members is unlimited by any measure that + reaches a real deployment. +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [40a93b5] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [8a483b3] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [776a098] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [c95ad19] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [0c2334f] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [52954c0] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [d028b37] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 6b9ee26008..daccf968b9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.2.0", + "version": "17.3.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 9a90e9af74..c9a36cf152 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,374 @@ # @objectstack/verify +## 17.3.0 + +### Patch Changes + +- 4f24e9d: feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + + **BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + + - New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. + - **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. + - `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. + - Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. + - `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. + - The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. + - `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + +- Updated dependencies [387e231] +- Updated dependencies [cae2169] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [e84bbf6] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [99ccbb9] +- Updated dependencies [c8be110] +- Updated dependencies [74cee59] +- Updated dependencies [6747718] +- Updated dependencies [340c5e5] +- Updated dependencies [399ecad] +- Updated dependencies [39d625f] +- Updated dependencies [277948f] +- Updated dependencies [8bdd955] +- Updated dependencies [7ef0268] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [4bd6faa] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [eae824e] +- Updated dependencies [f6fa22c] +- Updated dependencies [56d3c7a] +- Updated dependencies [8bb05ea] +- Updated dependencies [8a483b3] +- Updated dependencies [3bc2e38] +- Updated dependencies [7181101] +- Updated dependencies [df59de0] +- Updated dependencies [f75a38a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [23843d3] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [776a098] +- Updated dependencies [a58eac3] +- Updated dependencies [4f6325d] +- Updated dependencies [52954c0] +- Updated dependencies [0db5520] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [fa5d137] +- Updated dependencies [3b4c56c] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c95ad19] +- Updated dependencies [e7dfb1d] +- Updated dependencies [e25e839] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [86df0c9] +- Updated dependencies [5a22dd7] +- Updated dependencies [0d7b1f3] +- Updated dependencies [8155855] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [daeaaf9] +- Updated dependencies [c459da6] +- Updated dependencies [e914733] +- Updated dependencies [f887e52] +- Updated dependencies [3bfa1e6] +- Updated dependencies [901355c] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [c34f693] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [09b4f4e] +- Updated dependencies [30928a6] +- Updated dependencies [de47336] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [a3765f6] +- Updated dependencies [2d5cee3] +- Updated dependencies [0c2334f] +- Updated dependencies [66bbb4c] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [bd0c5cc] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [93ea19b] +- Updated dependencies [9ee2dcf] +- Updated dependencies [8cb96ec] +- Updated dependencies [8f10a79] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [a8c00e2] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9e72090] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [ce7e497] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [d79c602] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [0c77ea4] +- Updated dependencies [017130a] +- Updated dependencies [52954c0] +- Updated dependencies [466b389] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [d81838c] +- Updated dependencies [aa5994e] +- Updated dependencies [be93457] +- Updated dependencies [a65db76] +- Updated dependencies [2cf5a96] +- Updated dependencies [15eb2c9] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [225e769] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [31bb2e7] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [33184fd] +- Updated dependencies [db39dfc] +- Updated dependencies [3c0f3ea] +- Updated dependencies [26deb31] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [599515d] +- Updated dependencies [8d237b4] +- Updated dependencies [2d2e6f0] +- Updated dependencies [2d8dd8d] +- Updated dependencies [6a571d3] +- Updated dependencies [b5a2398] +- Updated dependencies [348860c] +- Updated dependencies [d48929e] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [f4e741b] +- Updated dependencies [fe72aa5] +- Updated dependencies [bc5156f] +- Updated dependencies [5700d83] +- Updated dependencies [21196cf] +- Updated dependencies [3519f8d] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [8965398] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [49f0dcf] +- Updated dependencies [033a34c] +- Updated dependencies [4d25d22] +- Updated dependencies [1ffee51] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [366f895] +- Updated dependencies [dc75ba8] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [ca1965f] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a3c4215] +- Updated dependencies [fc8627e] +- Updated dependencies [050d8d8] +- Updated dependencies [e0abc38] +- Updated dependencies [1e4d2eb] +- Updated dependencies [b853cf3] +- Updated dependencies [4af6c44] +- Updated dependencies [1272f0a] +- Updated dependencies [18b53ac] +- Updated dependencies [3194c91] +- Updated dependencies [65759ba] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [47d9b77] +- Updated dependencies [a933ed7] +- Updated dependencies [b3ca463] +- Updated dependencies [a933ed7] +- Updated dependencies [0d4a6a8] +- Updated dependencies [eeba2ef] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [9981f31] +- Updated dependencies [d028b37] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [750fff5] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [b799ac5] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [4d5b4f8] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [0da7cd2] +- Updated dependencies [28a5c3e] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/service-settings@17.3.0 + - @objectstack/service-analytics@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-automation@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/plugin-sharing@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 4604f35f57..6cce61d1eb 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.2.0", + "version": "17.3.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",